From 52378da653026d5db88d5e09641b2f8deeed8751 Mon Sep 17 00:00:00 2001 From: lhawkins Date: Tue, 17 Mar 2026 17:30:58 -0400 Subject: [PATCH 01/14] feat: Add lazy loading option for layers in GeoSet Multi Map Layers marked as "Lazy Loading" are loaded sequentially in the background after all eager layers have rendered, improving initial map load time for dashboards with many layers. Autozoom is automatically disabled for lazy-loaded layers. - Extract DeckSliceConfig, normalizeDeckSlices, and new resolveLayerAutozoom into multiUtils.ts for reuse and testability - Split layer loading into eager (parallel) and lazy (sequential) phases - Add Lazy Loading checkbox to per-layer settings popover in DeckSlicesControl - Add unit tests for resolveLayerAutozoom and normalizeDeckSlices Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/GeoSetMultiMap/Multi.tsx | 605 +++++++++--------- .../src/GeoSetMultiMap/multiUtils.ts | 53 ++ .../test/GeoSetMultiMap/Multi.test.ts | 116 ++++ .../controls/DeckSlicesControl/index.tsx | 80 ++- 4 files changed, 555 insertions(+), 299 deletions(-) create mode 100644 superset-frontend/plugins/geoset-map-chart/src/GeoSetMultiMap/multiUtils.ts create mode 100644 superset-frontend/plugins/geoset-map-chart/test/GeoSetMultiMap/Multi.test.ts diff --git a/superset-frontend/plugins/geoset-map-chart/src/GeoSetMultiMap/Multi.tsx b/superset-frontend/plugins/geoset-map-chart/src/GeoSetMultiMap/Multi.tsx index 350fdd2385..7b408d756a 100644 --- a/superset-frontend/plugins/geoset-map-chart/src/GeoSetMultiMap/Multi.tsx +++ b/superset-frontend/plugins/geoset-map-chart/src/GeoSetMultiMap/Multi.tsx @@ -54,6 +54,12 @@ import { fetchMapboxApiKey, getCachedMapboxApiKey } from '../utils/mapboxApi'; import { multiChartMigration } from '../utils/migrationApi'; import ClickPopupBox, { ClickedFeatureInfo } from '../components/ClickPopupBox'; import { setLiveViewport } from '../utils/liveViewportStore'; +import { + DeckSliceConfig, + resolveLayerAutozoom, + normalizeDeckSlices, +} from './multiUtils'; + // Apply enabled state to legend categories based on visibility map const applyCategoryEnabledState = ( categories: CategoryEntry[] | undefined, @@ -70,34 +76,6 @@ const toTitleCase = (str: string) => .replace(/_/g, ' ') .replace(/\w\S*/g, w => w.charAt(0).toUpperCase() + w.slice(1)); -// Per-layer config -interface DeckSliceConfig { - sliceId: number; - autozoom: boolean; - legendCollapsed: boolean; - initiallyHidden: boolean; -} - -// Normalize deck slices (handle legacy number[] format) -const normalizeDeckSlices = ( - deckSlices: (DeckSliceConfig | number)[] | undefined, -): DeckSliceConfig[] => - deckSlices?.map(item => - typeof item === 'number' - ? { - sliceId: item, - autozoom: true, - legendCollapsed: false, - initiallyHidden: false, - } - : { - sliceId: item.sliceId, - autozoom: item.autozoom ?? true, - legendCollapsed: item.legendCollapsed ?? false, - initiallyHidden: item.initiallyHidden ?? false, - }, - ) ?? []; - export type DeckMultiProps = { formData: QueryFormData; payload: JsonObject; @@ -130,6 +108,7 @@ type SubsliceLayerEntry = { }; zoomSliderOptions: { minZoom: number; maxZoom: number }; initiallyHidden: boolean; // Whether this layer starts hidden + lazyLoading: boolean; // Whether this layer was lazy-loaded }; interface ClickedFeatureWithColumns extends ClickedFeatureInfo { @@ -251,6 +230,235 @@ const DeckMulti = (props: DeckMultiProps) => { }); }, [normalizedDeckSlices, props, props.payload.data.slices]); + // Load a single subslice and return its layer entry + const loadSingleLayer = useCallback( + ( + formData: QueryFormData, + subslice: JsonObject, + sliceConfig: DeckSliceConfig | undefined, + ): Promise => { + const sliceAutozoom = resolveLayerAutozoom(sliceConfig); + const sliceLegendCollapsed = sliceConfig?.legendCollapsed ?? false; + const sliceInitiallyHidden = sliceConfig?.initiallyHidden ?? false; + const sliceLazyLoading = sliceConfig?.lazyLoading ?? false; + let copyFormData = { + ...subslice.form_data, + }; + if (formData.extraFormData) { + copyFormData = { + ...copyFormData, + extra_form_data: formData.extraFormData, + }; + } + + return multiChartMigration(copyFormData) + .then(migratedFormData => { + const subsliceCopy = { + ...subslice, + form_data: migratedFormData as QueryFormData, + }; + + const queryContext = buildGeoSetMapLayerQuery(subsliceCopy.form_data); + + return SupersetClient.post({ + endpoint: '/api/v1/chart/data', + jsonPayload: { ...queryContext }, + }).then(({ json }: { json: JsonObject }) => { + const result = json?.result?.[0] || {}; + const payload = { data: result.data || [] }; + + const chartProps = { + height: 400, + width: 600, + formData: subsliceCopy.form_data, + queriesData: [{ data: payload?.data || [] }], + hooks: { + onAddFilter: props.onAddFilter, + setControlValue: () => {}, + }, + } as any; + + const transformedProps = transformGeoSetMapLayerProps(chartProps); + + const sliceHoverColumnNames = transformedProps.hoverColumnNames; + const sliceFeatureInfoColumnNames = + transformedProps.featureInfoColumnNames; + const newLayer = getGeoSetMapLayer( + transformedProps.formData as any, + transformedProps.payload, + props.onAddFilter, + setTooltip, + transformedProps.categories || {}, + transformedProps.visualConfig, + sliceHoverColumnNames, + (info: any) => + handleFeatureClick(info, sliceFeatureInfoColumnNames), + ); + + if (!newLayer) { + return null; + } + + const payloadData = payload?.data || []; + const geometryType = getGeometryType(payloadData[0]?.geojson); + let transformPropsGeojsonLayer = + transformedProps.formData.geoJsonLayer; + + if ( + transformPropsGeojsonLayer !== 'TextOverlay' && + transformPropsGeojsonLayer !== geometryType + ) { + transformPropsGeojsonLayer = geometryType; + } + const transformedPropsConfig = + transformedProps.formData.geojsonConfig; + let icon; + let params; + const legendName = (() => { + try { + params = JSON.parse(transformedPropsConfig || '{}'); + icon = params.globalColoring.pointType; + if (params.legend) { + const formattedLegendName = toTitleCase(params.legend?.name); + return formattedLegendName || subslice.slice_name; + } + return subslice.slice_name; + } catch (e) { + return subslice.slice_name; + } + })(); + + const { categories, visualConfig } = transformedProps; + const { + dimension, + metricLegend, + sizeLegend, + isCombinedMetricSize, + } = visualConfig; + const hasCategories = + dimension && categories && Object.keys(categories).length > 0; + const hasMetric = + metricLegend !== null && metricLegend !== undefined; + + const legendTitle = params?.legend?.title + ? toTitleCase(params.legend.title) + : null; + const legendNameFromJson = params?.legend?.name + ? toTitleCase(params.legend.name) + : null; + + const buildSizeEntry = () => + sizeLegend ? { ...sizeLegend } : undefined; + + let legendEntry: LegendEntry; + + if (hasMetric) { + const ml = metricLegend as MetricLegend; + const isCombined = isCombinedMetricSize === true; + legendEntry = { + legendName: legendTitle || legendName, + sliceName: subslice.slice_name, + icon, + geometryType: transformPropsGeojsonLayer, + type: 'metric', + metric: { + lower: ml.min, + upper: ml.max, + startColor: ml.startColor, + endColor: ml.endColor, + usesPercentBounds: ml.usesPercentBounds, + }, + sizeEntry: isCombined ? buildSizeEntry() : undefined, + isCombinedMetricSize: isCombined, + initialCollapsed: sliceLegendCollapsed, + }; + } else if (hasCategories) { + const categoryEntries = Object.entries( + categories as Record, + ) + .filter(([_, catState]) => catState.enabled !== false) + .map(([label, catState]) => ({ + label: catState.legend_name || label, + fillColor: catState.color, + strokeColor: visualConfig.strokeColor as RGBAColor, + })); + + legendEntry = { + legendName: legendTitle || legendName, + sliceName: subslice.slice_name, + icon, + geometryType: transformPropsGeojsonLayer, + type: 'categorical', + categories: categoryEntries, + sizeEntry: buildSizeEntry(), + initialCollapsed: sliceLegendCollapsed, + }; + } else { + const fillColor = visualConfig.fillColor as RGBAColor; + const strokeColor = visualConfig.strokeColor as RGBAColor; + + legendEntry = { + legendName: legendNameFromJson || legendName, + legendParentTitle: legendTitle || subslice.slice_name, + sliceName: subslice.slice_name, + icon, + geometryType: transformPropsGeojsonLayer, + type: 'simple', + simpleStyle: { + fillColor, + strokeColor, + }, + sizeEntry: buildSizeEntry(), + initialCollapsed: sliceLegendCollapsed, + }; + } + + const zoomSlider = subsliceCopy.form_data.minMaxZoomSlider || [ + 0, 22, + ]; + const newLayerStateOptions = { + minZoom: zoomSlider[0], + maxZoom: zoomSlider[1], + }; + + const newLayerStates = layerStatesGenerator( + newLayer, + newLayerStateOptions, + ); + + if (!newLayerStates.length) { + return null; + } + + const layerFeatures: JsonObject[] = + transformedProps.payload?.data?.features || []; + + return { + sliceId: subslice.slice_id as number, + layerStates: newLayerStates, + legendEntry, + features: layerFeatures, + autozoom: sliceAutozoom, + transformedProps: { + formData: transformedProps.formData, + payload: transformedProps.payload, + categories: transformedProps.categories || {}, + visualConfig: transformedProps.visualConfig, + hoverColumnNames: transformedProps.hoverColumnNames, + featureInfoColumnNames: + transformedProps.featureInfoColumnNames || [], + }, + zoomSliderOptions: newLayerStateOptions, + initiallyHidden: sliceInitiallyHidden, + lazyLoading: sliceLazyLoading, + }; + }); + }) + .catch(() => null); + }, + [props.onAddFilter, setTooltip, handleFeatureClick], + ); + const loadLayers = useCallback( ( formData: QueryFormData, @@ -263,260 +471,80 @@ const DeckMulti = (props: DeckMultiProps) => { return; } - Promise.all( - slices.map((subslice: { slice_id: number } & JsonObject) => { - // Get layer settings from the config - const sliceConfig = deckSlicesConfig.find( - c => c.sliceId === subslice.slice_id, - ); - const sliceAutozoom = sliceConfig?.autozoom ?? true; - const sliceLegendCollapsed = sliceConfig?.legendCollapsed ?? false; - const sliceInitiallyHidden = sliceConfig?.initiallyHidden ?? false; - let copyFormData = { - ...subslice.form_data, - }; - if (formData.extraFormData) { - copyFormData = { - ...copyFormData, - extra_form_data: formData.extraFormData, - }; - } - - // Migrate form_data if needed, then build query and fetch data - return multiChartMigration(copyFormData) - .then(migratedFormData => { - const subsliceCopy = { - ...subslice, - form_data: migratedFormData as QueryFormData, - }; + // Split slices into eager (non-lazy) and lazy groups + const eagerSlices: JsonObject[] = []; + const lazySlices: JsonObject[] = []; - const queryContext = buildGeoSetMapLayerQuery( - subsliceCopy.form_data, - ); + slices.forEach((subslice: JsonObject) => { + const config = deckSlicesConfig.find( + c => c.sliceId === subslice.slice_id, + ); + if (config?.lazyLoading) { + lazySlices.push(subslice); + } else { + eagerSlices.push(subslice); + } + }); - return SupersetClient.post({ - endpoint: '/api/v1/chart/data', - jsonPayload: { ...queryContext }, - }).then(({ json }: { json: JsonObject }) => { - // Transform API response to match expected format - const result = json?.result?.[0] || {}; - const payload = { data: result.data || [] }; - - // Build ChartProps-like object for transformProps - const chartProps = { - height: 400, - width: 600, - formData: subsliceCopy.form_data, - queriesData: [{ data: payload?.data || [] }], - hooks: { - onAddFilter: props.onAddFilter, - setControlValue: () => {}, - }, - } as any; - - // Use transformProps to process data (same logic as standalone chart) - const transformedProps = - transformGeoSetMapLayerProps(chartProps); - - const sliceHoverColumnNames = transformedProps.hoverColumnNames; - const sliceFeatureInfoColumnNames = - transformedProps.featureInfoColumnNames; - const newLayer = getGeoSetMapLayer( - transformedProps.formData as any, - transformedProps.payload, - props.onAddFilter, - setTooltip, - transformedProps.categories || {}, - transformedProps.visualConfig, - sliceHoverColumnNames, - (info: any) => - handleFeatureClick(info, sliceFeatureInfoColumnNames), + // Phase 1: Load all eager (non-lazy) layers in parallel + const eagerPromise = + eagerSlices.length > 0 + ? Promise.all( + eagerSlices.map(subslice => { + const config = deckSlicesConfig.find( + c => c.sliceId === subslice.slice_id, ); - - if (!newLayer) { - return null; - } - // Extract legend name from form_data.params.geojsonConfig or fall back to slice name - const payloadData = payload?.data || []; - const geometryType = getGeometryType(payloadData[0]?.geojson); - let transformPropsGeojsonLayer = - transformedProps.formData.geoJsonLayer; - - // Preserve TextOverlay as the legend geometry type - if ( - transformPropsGeojsonLayer !== 'TextOverlay' && - transformPropsGeojsonLayer !== geometryType - ) { - transformPropsGeojsonLayer = geometryType; - } - const transformedPropsConfig = - transformedProps.formData.geojsonConfig; - let icon; // need to get icon from json payload - let params; - const legendName = (() => { - try { - params = JSON.parse(transformedPropsConfig || '{}'); - icon = params.globalColoring.pointType; - if (params.legend) { - const formattedLegendName = toTitleCase( - params.legend?.name, + return loadSingleLayer(formData, subslice, config); + }), + ).then(results => + results.filter( + (entry): entry is SubsliceLayerEntry => entry !== null, + ), + ) + : Promise.resolve([] as SubsliceLayerEntry[]); + + console.time('[GeoSet] eager-layers'); + eagerPromise.then(eagerLayers => { + console.timeEnd('[GeoSet] eager-layers'); + console.log( + `[GeoSet] ${eagerLayers.length} eager layer(s) loaded, map rendering`, + ); + // Set eager layers immediately so the map renders + setSubSlicesLayers(eagerLayers); + + if (lazySlices.length === 0) return; + + // Phase 2: Load lazy layers one-by-one, appending each as it completes + console.time('[GeoSet] lazy-layers'); + let lazyCount = 0; + lazySlices + .reduce( + (chain, subslice) => + chain.then(() => { + const config = deckSlicesConfig.find( + c => c.sliceId === subslice.slice_id, + ); + return loadSingleLayer(formData, subslice, config).then( + layerEntry => { + if (layerEntry) { + lazyCount += 1; + console.log( + `[GeoSet] lazy layer ${lazyCount}/${lazySlices.length} loaded (slice ${subslice.slice_id})`, ); - return formattedLegendName || subslice.slice_name; + setSubSlicesLayers(prev => [...prev, layerEntry]); } - return subslice.slice_name; - } catch (e) { - return subslice.slice_name; - } - })(); - - // Build the LegendEntry based on what coloring mode is active - const { categories, visualConfig } = transformedProps; - const { - dimension, - metricLegend, - sizeLegend, - isCombinedMetricSize, - } = visualConfig; - const hasCategories = - dimension && categories && Object.keys(categories).length > 0; - const hasMetric = - metricLegend !== null && metricLegend !== undefined; - - // Get legend config from JSON - // For categorical: legend.title is the header, legend.name is null - // For simple/base: legend.title is the header, legend.name is the expanded content - const legendTitle = params?.legend?.title - ? toTitleCase(params.legend.title) - : null; - const legendNameFromJson = params?.legend?.name - ? toTitleCase(params.legend.name) - : null; - - const buildSizeEntry = () => - sizeLegend ? { ...sizeLegend } : undefined; - - let legendEntry: LegendEntry; - - if (hasMetric) { - // Metric-based coloring (gradient) - // Use legend.title from JSON for legend header - const ml = metricLegend as MetricLegend; - const isCombined = isCombinedMetricSize === true; - legendEntry = { - legendName: legendTitle || legendName, - sliceName: subslice.slice_name, - icon, - geometryType: transformPropsGeojsonLayer, - type: 'metric', - metric: { - lower: ml.min, - upper: ml.max, - startColor: ml.startColor, - endColor: ml.endColor, - usesPercentBounds: ml.usesPercentBounds, - }, - sizeEntry: isCombined ? buildSizeEntry() : undefined, - isCombinedMetricSize: isCombined, - initialCollapsed: sliceLegendCollapsed, - }; - } else if (hasCategories) { - // Category-based coloring - // Use legend.title from JSON for legend header (legend.name is null) - const categoryEntries = Object.entries( - categories as Record, - ) - .filter(([_, catState]) => catState.enabled !== false) - .map(([label, catState]) => ({ - label: catState.legend_name || label, - fillColor: catState.color, - strokeColor: visualConfig.strokeColor as RGBAColor, - })); - - legendEntry = { - legendName: legendTitle || legendName, - sliceName: subslice.slice_name, - icon, - geometryType: transformPropsGeojsonLayer, - type: 'categorical', - categories: categoryEntries, - sizeEntry: buildSizeEntry(), - initialCollapsed: sliceLegendCollapsed, - }; - } else { - // Simple/static coloring (base charts - no categories or metrics) - // legendParentTitle = legend.title (shown as header) - // legendName = legend.name (shown in expanded content) - const fillColor = visualConfig.fillColor as RGBAColor; - const strokeColor = visualConfig.strokeColor as RGBAColor; - - legendEntry = { - legendName: legendNameFromJson || legendName, - legendParentTitle: legendTitle || subslice.slice_name, - sliceName: subslice.slice_name, - icon, - geometryType: transformPropsGeojsonLayer, - type: 'simple', - simpleStyle: { - fillColor, - strokeColor, - }, - sizeEntry: buildSizeEntry(), - initialCollapsed: sliceLegendCollapsed, - }; - } - - const zoomSlider = subsliceCopy.form_data.minMaxZoomSlider || [ - 0, 22, - ]; - const newLayerStateOptions = { - minZoom: zoomSlider[0], - maxZoom: zoomSlider[1], - }; - - const newLayerStates = layerStatesGenerator( - newLayer, - newLayerStateOptions, - ); - - if (!newLayerStates.length) { - return null; - } - - // Store layer with its features for autozoom calculation - const layerFeatures: JsonObject[] = - transformedProps.payload?.data?.features || []; - - return { - sliceId: subsliceCopy.slice_id, - layerStates: newLayerStates, - legendEntry, - features: layerFeatures, - autozoom: sliceAutozoom, - // Store data needed to rebuild layer when category visibility changes - transformedProps: { - formData: transformedProps.formData, - payload: transformedProps.payload, - categories: transformedProps.categories || {}, - visualConfig: transformedProps.visualConfig, - hoverColumnNames: transformedProps.hoverColumnNames, - featureInfoColumnNames: - transformedProps.featureInfoColumnNames || [], }, - zoomSliderOptions: newLayerStateOptions, - initiallyHidden: sliceInitiallyHidden, - }; - }); - }) - .catch(() => null); - }), - ).then(results => { - const validLayers = results.filter( - (entry): entry is SubsliceLayerEntry => entry !== null, - ); - setSubSlicesLayers(validLayers); + ); + }), + Promise.resolve(), + ) + .then(() => { + console.timeEnd('[GeoSet] lazy-layers'); + console.log('[GeoSet] all layers loaded'); + }); }); }, - [props.onAddFilter, setTooltip, handleFeatureClick], + [loadSingleLayer], ); const prevSlicesData = usePrevious(slicesData); @@ -533,25 +561,32 @@ const DeckMulti = (props: DeckMultiProps) => { normalizedDeckSlices, ]); - // Sync autozoom settings when they change (without reloading layers) + // Sync autozoom and lazyLoading settings when they change (without reloading layers) useEffect(() => { setSubSlicesLayers(currentLayers => { if (!currentLayers.length) return currentLayers; - const autozoomMap = new Map( - normalizedDeckSlices.map(c => [c.sliceId, c.autozoom]), - ); + const configMap = new Map(normalizedDeckSlices.map(c => [c.sliceId, c])); - const needsUpdate = currentLayers.some( - layer => layer.autozoom !== (autozoomMap.get(layer.sliceId) ?? true), - ); + const needsUpdate = currentLayers.some(layer => { + const config = configMap.get(layer.sliceId); + const expectedAutozoom = resolveLayerAutozoom(config); + return ( + layer.autozoom !== expectedAutozoom || + layer.lazyLoading !== (config?.lazyLoading ?? false) + ); + }); if (!needsUpdate) return currentLayers; - return currentLayers.map(layer => ({ - ...layer, - autozoom: autozoomMap.get(layer.sliceId) ?? true, - })); + return currentLayers.map(layer => { + const config = configMap.get(layer.sliceId); + return { + ...layer, + autozoom: resolveLayerAutozoom(config), + lazyLoading: config?.lazyLoading ?? false, + }; + }); }); }, [normalizedDeckSlices]); diff --git a/superset-frontend/plugins/geoset-map-chart/src/GeoSetMultiMap/multiUtils.ts b/superset-frontend/plugins/geoset-map-chart/src/GeoSetMultiMap/multiUtils.ts new file mode 100644 index 0000000000..98f0b7550e --- /dev/null +++ b/superset-frontend/plugins/geoset-map-chart/src/GeoSetMultiMap/multiUtils.ts @@ -0,0 +1,53 @@ +/** + * 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. + */ + +export interface DeckSliceConfig { + sliceId: number; + autozoom: boolean; + legendCollapsed: boolean; + initiallyHidden: boolean; + lazyLoading: boolean; +} + +/** Resolve effective autozoom for a slice: disabled when lazy loading is on */ +export const resolveLayerAutozoom = ( + config: DeckSliceConfig | undefined, +): boolean => (config?.lazyLoading ? false : (config?.autozoom ?? true)); + +/** Normalize deck slices — converts legacy number[] format to DeckSliceConfig[] */ +export const normalizeDeckSlices = ( + deckSlices: (DeckSliceConfig | number)[] | undefined, +): DeckSliceConfig[] => + deckSlices?.map(item => + typeof item === 'number' + ? { + sliceId: item, + autozoom: true, + legendCollapsed: false, + initiallyHidden: false, + lazyLoading: false, + } + : { + sliceId: item.sliceId, + autozoom: item.autozoom ?? true, + legendCollapsed: item.legendCollapsed ?? false, + initiallyHidden: item.initiallyHidden ?? false, + lazyLoading: item.lazyLoading ?? false, + }, + ) ?? []; diff --git a/superset-frontend/plugins/geoset-map-chart/test/GeoSetMultiMap/Multi.test.ts b/superset-frontend/plugins/geoset-map-chart/test/GeoSetMultiMap/Multi.test.ts new file mode 100644 index 0000000000..880dc9c6e3 --- /dev/null +++ b/superset-frontend/plugins/geoset-map-chart/test/GeoSetMultiMap/Multi.test.ts @@ -0,0 +1,116 @@ +/** + * 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 { + resolveLayerAutozoom, + normalizeDeckSlices, +} from '../../src/GeoSetMultiMap/multiUtils'; + +describe('resolveLayerAutozoom', () => { + it('disables autozoom when lazyLoading is true', () => { + expect( + resolveLayerAutozoom({ + sliceId: 1, + autozoom: true, + legendCollapsed: false, + initiallyHidden: false, + lazyLoading: true, + }), + ).toBe(false); + }); + + it('disables autozoom when lazyLoading is true even if autozoom is explicitly true', () => { + expect( + resolveLayerAutozoom({ + sliceId: 1, + autozoom: true, + legendCollapsed: false, + initiallyHidden: false, + lazyLoading: true, + }), + ).toBe(false); + }); + + it('respects autozoom setting when lazyLoading is false', () => { + expect( + resolveLayerAutozoom({ + sliceId: 1, + autozoom: true, + legendCollapsed: false, + initiallyHidden: false, + lazyLoading: false, + }), + ).toBe(true); + }); + + it('respects autozoom: false when lazyLoading is false', () => { + expect( + resolveLayerAutozoom({ + sliceId: 1, + autozoom: false, + legendCollapsed: false, + initiallyHidden: false, + lazyLoading: false, + }), + ).toBe(false); + }); + + it('defaults autozoom to true when config is undefined', () => { + expect(resolveLayerAutozoom(undefined)).toBe(true); + }); +}); + +describe('normalizeDeckSlices', () => { + it('sets lazyLoading to false by default for legacy number entries', () => { + const result = normalizeDeckSlices([1, 2]); + result.forEach(slice => { + expect(slice.lazyLoading).toBe(false); + }); + }); + + it('preserves lazyLoading: true from config objects', () => { + const result = normalizeDeckSlices([ + { + sliceId: 1, + autozoom: true, + legendCollapsed: false, + initiallyHidden: false, + lazyLoading: true, + }, + ]); + expect(result[0].lazyLoading).toBe(true); + }); + + it('autozoom resolves to false for a normalized lazy slice', () => { + const result = normalizeDeckSlices([ + { + sliceId: 1, + autozoom: true, + legendCollapsed: false, + initiallyHidden: false, + lazyLoading: true, + }, + ]); + expect(resolveLayerAutozoom(result[0])).toBe(false); + }); + + it('returns empty array when input is undefined', () => { + expect(normalizeDeckSlices(undefined)).toEqual([]); + }); +}); diff --git a/superset-frontend/src/explore/components/controls/DeckSlicesControl/index.tsx b/superset-frontend/src/explore/components/controls/DeckSlicesControl/index.tsx index b239593388..ef0776ccfb 100644 --- a/superset-frontend/src/explore/components/controls/DeckSlicesControl/index.tsx +++ b/superset-frontend/src/explore/components/controls/DeckSlicesControl/index.tsx @@ -47,6 +47,7 @@ export interface DeckSliceConfig { autozoom: boolean; legendCollapsed: boolean; initiallyHidden: boolean; + lazyLoading: boolean; } export interface DeckSlicesControlProps { @@ -96,6 +97,7 @@ interface SliceSettings { autozoom: boolean; legendCollapsed: boolean; initiallyHidden: boolean; + lazyLoading: boolean; } interface SelectedSliceRowProps { @@ -104,6 +106,7 @@ interface SelectedSliceRowProps { autozoom: boolean; legendCollapsed: boolean; initiallyHidden: boolean; + lazyLoading: boolean; index: number; onRemove: (sliceId: number) => void; onMoveLabel: (dragIndex: number, hoverIndex: number) => void; @@ -166,6 +169,7 @@ const SelectedSliceRow = ({ autozoom, legendCollapsed, initiallyHidden, + lazyLoading, index, onRemove, onMoveLabel, @@ -183,6 +187,7 @@ const SelectedSliceRow = ({ useState(legendCollapsed); const [draftInitiallyHidden, setDraftInitiallyHidden] = useState(initiallyHidden); + const [draftLazyLoading, setDraftLazyLoading] = useState(lazyLoading); // Reset draft state when popover opens const handleOpenChange = (open: boolean) => { @@ -190,21 +195,38 @@ const SelectedSliceRow = ({ setDraftAutozoom(autozoom); setDraftLegendCollapsed(legendCollapsed); setDraftInitiallyHidden(initiallyHidden); + setDraftLazyLoading(lazyLoading); } setSettingsOpen(open); }; + // When lazy loading is toggled on, auto-disable autozoom + const handleToggleLazyLoading = () => { + const newLazyLoading = !draftLazyLoading; + setDraftLazyLoading(newLazyLoading); + if (newLazyLoading) { + setDraftAutozoom(false); + } + }; + // Apply draft changes on Save - apply ALL changes in one update const handleSave = () => { const autozoomChanged = draftAutozoom !== autozoom; const legendCollapsedChanged = draftLegendCollapsed !== legendCollapsed; const initiallyHiddenChanged = draftInitiallyHidden !== initiallyHidden; - - if (autozoomChanged || legendCollapsedChanged || initiallyHiddenChanged) { + const lazyLoadingChanged = draftLazyLoading !== lazyLoading; + + if ( + autozoomChanged || + legendCollapsedChanged || + initiallyHiddenChanged || + lazyLoadingChanged + ) { onUpdateSliceSettings(sliceId, { autozoom: draftAutozoom, legendCollapsed: draftLegendCollapsed, initiallyHidden: draftInitiallyHidden, + lazyLoading: draftLazyLoading, }); } setSettingsOpen(false); @@ -240,25 +262,29 @@ const SelectedSliceRow = ({ labelRef.current && labelRef.current.scrollWidth > labelRef.current.clientWidth; + const autozoomDisabled = staticViewportEnabled || draftLazyLoading; + const autozoomTooltip = (() => { + if (staticViewportEnabled) { + return t( + 'Auto Zoom is disabled when Static Viewport is enabled on the map', + ); + } + if (draftLazyLoading) { + return t('Auto Zoom is disabled when Lazy Loading is enabled'); + } + return t('Automatically zoom the map to fit this layer'); + })(); + const settingsContent = ( e.stopPropagation()}> - + setDraftAutozoom(!draftAutozoom)} - disabled={staticViewportEnabled} + disabled={autozoomDisabled} /> {t('Auto Zoom')} - + + + + {t('Lazy Loading')} + + + + + +