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 56f242f7b9..21767a70b5 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, + loadLayersOrchestrated, +} from './multiUtils'; import { applyCategoryEnabledState } from '../utils/legendHelpers'; // Utility to convert snake_case or camelCase to Title Case @@ -62,33 +68,40 @@ 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; -} +/** Build a placeholder legend entry from slice metadata (before layer data is fetched). */ +const buildStubLegendEntry = ( + subslice: JsonObject, + sliceConfig: DeckSliceConfig | undefined, +): LegendEntry => { + const geojsonConfig = subslice.form_data?.geojsonConfig; + let icon: string | undefined; + let legendName: string = subslice.slice_name as string; + let legendTitle: string | null = null; + + try { + const params = JSON.parse(geojsonConfig || '{}'); + icon = params.globalColoring?.pointType; + if (params.legend?.name) { + legendName = toTitleCase(params.legend.name); + } + if (params.legend?.title) { + legendTitle = toTitleCase(params.legend.title); + } + } catch { + // Fall back to slice_name + } -// 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, - }, - ) ?? []; + return { + legendName: legendTitle || legendName, + legendParentTitle: legendTitle || (subslice.slice_name as string), + sliceName: subslice.slice_name as string, + icon, + geometryType: subslice.form_data?.geoJsonLayer, + type: 'simple', + initialCollapsed: sliceConfig?.legendCollapsed ?? false, + loading: true, + }; +}; export type DeckMultiProps = { formData: QueryFormData; @@ -122,6 +135,7 @@ type SubsliceLayerEntry = { }; zoomSliderOptions: { minZoom: number; maxZoom: number }; initiallyHidden: boolean; // Whether this layer starts hidden + lazyLoading: boolean; // Whether this layer is configured for lazy loading }; interface ClickedFeatureWithColumns extends ClickedFeatureInfo { @@ -132,6 +146,8 @@ const DeckMulti = (props: DeckMultiProps) => { const containerRef = useRef(null); // Ref to track measure state for use in callbacks without creating dependencies const measureActiveRef = useRef(false); + // Generation counter to cancel stale lazy-loading chains + const loadGenerationRef = useRef(0); // Store initial autozoom viewport to prevent reset on category toggle const initialAutozoomViewportRef = useRef(null); @@ -202,6 +218,21 @@ const DeckMulti = (props: DeckMultiProps) => { [props.formData.deckSlices], ); + // Build stub legend entries from slice metadata (shown while layer data loads) + const pendingLegends: Record = useMemo(() => { + if (!slicesData?.length) return {}; + const configById = new Map(normalizedDeckSlices.map(c => [c.sliceId, c])); + return Object.fromEntries( + slicesData.map((subslice: JsonObject) => [ + String(subslice.slice_id), + buildStubLegendEntry( + subslice, + configById.get(subslice.slice_id as number), + ), + ]), + ); + }, [slicesData, normalizedDeckSlices]); + // Fetch slice metadata when deckSlices changes and payload doesn't have slices useEffect(() => { const { payload } = props; @@ -243,272 +274,320 @@ const DeckMulti = (props: DeckMultiProps) => { }); }, [normalizedDeckSlices, props, props.payload.data.slices]); - const loadLayers = useCallback( + // Load a single subslice and return its layer entry + const loadSingleLayer = useCallback( ( formData: QueryFormData, - slices: JsonObject[], - deckSlicesConfig: DeckSliceConfig[], - ) => { - setSubSlicesLayers([]); - - if (!slices || slices.length === 0) { - return; + 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, + }; } - 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, + return ( + multiChartMigration(copyFormData) + .then(migratedFormData => { + const subsliceCopy = { + ...subslice, + form_data: migratedFormData as QueryFormData, }; - } - // Migrate form_data if needed, then build query and fetch data - return multiChartMigration(copyFormData) - .then(migratedFormData => { - const subsliceCopy = { - ...subslice, - form_data: migratedFormData as QueryFormData, - }; + const queryContext = buildGeoSetMapLayerQuery( + subsliceCopy.form_data, + ); - 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), ); - 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), - ); - - 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 formattedLegendName || subslice.slice_name; - } - return subslice.slice_name; - } catch (e) { - return subslice.slice_name; + 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; } - })(); - - // 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, - }; + return subslice.slice_name; + } catch (e) { + return subslice.slice_name; } - - const zoomSlider = subsliceCopy.form_data.minMaxZoomSlider || [ - 0, 22, - ]; - const newLayerStateOptions = { - minZoom: zoomSlider[0], - maxZoom: zoomSlider[1], + })(); + + 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, - ); + const newLayerStates = layerStatesGenerator( + newLayer, + newLayerStateOptions, + ); - if (!newLayerStates.length) { - return null; - } + 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, + }; + }); + }) + // IMPORTANT: This .catch is load-bearing. It ensures a single layer + // failure resolves to null instead of rejecting, which would abort the + // entire lazy-loading reduce chain or the eager Promise.all batch. + .catch(err => { + // eslint-disable-next-line no-console + console.error( + `[GeoSet] Failed to load layer for slice ${subslice.slice_id}:`, + err, + ); + return null; + }) + ); + }, + [props.onAddFilter, setTooltip, handleFeatureClick], + ); - // 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, + const loadLayers = useCallback( + ( + formData: QueryFormData, + slices: JsonObject[], + deckSlicesConfig: DeckSliceConfig[], + ) => { + // Bump generation so any in-flight lazy chain from a prior call is ignored + // eslint-disable-next-line no-plusplus + const generation = ++loadGenerationRef.current; + setSubSlicesLayers([]); + + // Pre-set layer visibility from config — known upfront, no timing + // dependency on when layers finish loading. + const hiddenConfigs = deckSlicesConfig.filter(c => c.initiallyHidden); + if (hiddenConfigs.length > 0) { + setLayerVisibility(prev => ({ + ...prev, + ...Object.fromEntries( + hiddenConfigs.map(c => [String(c.sliceId), false]), + ), + })); + } + + // Category visibility can only be set once the layer loads (categories + // aren't known until data is fetched). + const hideCategoriesIfNeeded = (layers: SubsliceLayerEntry[]) => { + const categorical = layers.filter( + l => l.initiallyHidden && l.legendEntry.categories?.length, ); - setSubSlicesLayers(validLayers); + if (categorical.length === 0) return; + setCategoryVisibility(prev => ({ + ...prev, + ...Object.fromEntries( + categorical.map(l => [ + String(l.sliceId), + Object.fromEntries( + l.legendEntry.categories!.map(c => [c.label, false]), + ), + ]), + ), + })); + }; + + loadLayersOrchestrated( + slices as { slice_id: number }[], + deckSlicesConfig, + { + loadFn: (subslice, config) => + loadSingleLayer(formData, subslice, config), + onAutozoomComplete: autozoomLayers => { + setSubSlicesLayers(autozoomLayers); + hideCategoriesIfNeeded(autozoomLayers); + }, + onEagerAppend: layer => { + setSubSlicesLayers(prev => [...prev, layer]); + hideCategoriesIfNeeded([layer]); + }, + onLazyAppend: layer => { + setSubSlicesLayers(prev => [...prev, layer]); + hideCategoriesIfNeeded([layer]); + }, + isStale: () => loadGenerationRef.current !== generation, + }, + ).catch(err => { + // eslint-disable-next-line no-console + console.error('[GeoSet] Layer orchestration failed:', err); }); }, - [props.onAddFilter, setTooltip, handleFeatureClick], + [loadSingleLayer], ); const prevSlicesData = usePrevious(slicesData); @@ -525,56 +604,35 @@ 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]); - // Initialize layer visibility based on initiallyHidden setting - // Runs once when layers are first loaded - const prevSubSlicesLayersLength = usePrevious(subSlicesLayers.length); - useEffect(() => { - if (prevSubSlicesLayersLength === 0 && subSlicesLayers.length > 0) { - const hiddenLayers = subSlicesLayers.filter(e => e.initiallyHidden); - if (hiddenLayers.length > 0) { - // Hide the layers - setLayerVisibility( - Object.fromEntries(hiddenLayers.map(e => [String(e.sliceId), false])), - ); - // Also turn off all categories for hidden categorical layers - setCategoryVisibility( - Object.fromEntries( - hiddenLayers - .filter(e => e.legendEntry.categories?.length) - .map(e => [ - String(e.sliceId), - Object.fromEntries( - e.legendEntry.categories!.map(c => [c.label, false]), - ), - ]), - ), - ); - } - } - }, [subSlicesLayers, prevSubSlicesLayersLength]); - const { height, width } = props; // Toggle layer visibility callback (supports legend groups with multiple sliceIds) @@ -800,48 +858,64 @@ const DeckMulti = (props: DeckMultiProps) => { // Mark hidden layers with userVisible: false so deck.gl keeps them alive // but skips rendering. This allows instant toggle-back without reinitializing. // flatMap because polygon layers produce multiple LayerStates (fill + stroke) - const layerStatesWithVisibility = sortedLayers.flatMap(entry => { - const isVisible = layerVisibility[String(entry.sliceId)] !== false; - return entry.layerStates.map(ls => ({ - ...ls, - options: { - ...ls.options, - userVisible: isVisible, - }, - })); - }); - - // Build legendsBySlice for MultiLegend component, with category enabled state applied - const legendsBySlice: Record = useMemo( + // Memoized so DeckGLContainer only sees new prop references when visibility + // actually changes — prevents a spurious extra render on every Multi re-render. + const layerStatesWithVisibility = useMemo( () => - Object.fromEntries( - sortedLayers.map(entry => { - const sliceId = String(entry.sliceId); - const { legendEntry } = entry; - - // If no categories, return as-is - if (!legendEntry.categories) { - return [sliceId, legendEntry]; - } + sortedLayers.flatMap(entry => { + const visKey = String(entry.sliceId); + const isVisible = + visKey in layerVisibility + ? layerVisibility[visKey] !== false + : !entry.initiallyHidden; + return entry.layerStates.map(ls => ({ + ...ls, + options: { + ...ls.options, + userVisible: isVisible, + }, + })); + }), + [sortedLayers, layerVisibility], + ); - // Apply category visibility state - const sliceCatVisibility = categoryVisibility[sliceId] || {}; - const updatedCategories = applyCategoryEnabledState( + // Build legendsBySlice for MultiLegend component, with category enabled state applied. + // Merges loaded entries with stub entries so the legend shows all layers immediately. + const legendsBySlice: Record = useMemo(() => { + const loadedById = new Map(); + sortedLayers.forEach(entry => { + const sliceId = String(entry.sliceId); + const { legendEntry } = entry; + if (!legendEntry.categories) { + loadedById.set(sliceId, legendEntry); + } else { + const sliceCatVisibility = categoryVisibility[sliceId] || {}; + loadedById.set(sliceId, { + ...legendEntry, + categories: applyCategoryEnabledState( legendEntry.categories, sliceCatVisibility, - )!; - - return [ - sliceId, - { - ...legendEntry, - categories: updatedCategories, - }, - ]; - }), - ), - [sortedLayers, categoryVisibility], - ); + )!, + }); + } + }); + + // Iterate in config order (reversed to match sortedLayers convention). + // Loaded entries replace stubs; unloaded layers keep showing stubs. + const result: Record = {}; + const configOrder = [...normalizedDeckSlices].reverse(); + for (const config of configOrder) { + const sliceId = String(config.sliceId); + const loaded = loadedById.get(sliceId); + if (loaded) { + result[sliceId] = loaded; + } else if (pendingLegends[sliceId]) { + result[sliceId] = pendingLegends[sliceId]; + } + } + + return result; + }, [pendingLegends, sortedLayers, categoryVisibility, normalizedDeckSlices]); // Group legend entries that share the same display title const legendGroups = useGroupedLegend(legendsBySlice); @@ -999,9 +1073,19 @@ const DeckMulti = (props: DeckMultiProps) => { return () => document.removeEventListener('keydown', handleKeyDown); }, [measureState.isActive]); - // Show loading state until slices data is fetched and layers are processed + // Gate map canvas rendering to prevent a viewport jump when autozoom layers + // load. Phase 1 loads autozoom layers first; the canvas stays hidden until + // they complete so the viewport is correct on first render. + // + // - If any layer has autozoom: wait for phase 1 (autozoom batch) to complete. + // - If no layers have autozoom: show the map as soon as metadata is fetched. const hasChartsToLoad = normalizedDeckSlices.length > 0; - const isLoading = hasChartsToLoad && subSlicesLayers.length === 0; + const hasAutozoomLayers = normalizedDeckSlices.some(config => + resolveLayerAutozoom(config), + ); + const isLoading = + hasChartsToLoad && + (hasAutozoomLayers ? subSlicesLayers.length === 0 : slicesData === null); if (isLoading) { return ( 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..789461237a --- /dev/null +++ b/superset-frontend/plugins/geoset-map-chart/src/GeoSetMultiMap/multiUtils.ts @@ -0,0 +1,167 @@ +/** + * 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, + }, + ) ?? []; + +/** Callbacks for {@link loadLayersOrchestrated}. */ +export interface OrchestrationCallbacks { + /** Load a single slice, returning null on failure. */ + loadFn: ( + subslice: { slice_id: number }, + config: DeckSliceConfig | undefined, + ) => Promise; + /** Called once with autozoom layers after they finish loading (phase 1). */ + onAutozoomComplete: (layers: TLayer[]) => void; + /** Called for each non-autozoom eager layer as it finishes (phase 2). */ + onEagerAppend: (layer: TLayer) => void; + /** Called for each lazy layer as it finishes loading sequentially (phase 3). */ + onLazyAppend: (layer: TLayer) => void; + /** Return true to abort — checked before each phase and between lazy loads. */ + isStale: () => boolean; +} + +/** Number of lazy layers to load concurrently in each batch. */ +const LAZY_BATCH_SIZE = 2; + +/** + * Orchestrates three-phase layer loading: + * Phase 1 — load autozoom layers in parallel (map canvas gates on this). + * Phase 2 — load remaining eager layers in parallel, appending each as it finishes. + * Phase 3 — load lazy layers in small batches, appending each as it finishes. + * + * Returns a promise that resolves when the full chain finishes + * or is aborted due to staleness. + */ +export function loadLayersOrchestrated( + slices: { slice_id: number }[], + deckSlicesConfig: DeckSliceConfig[], + callbacks: OrchestrationCallbacks, +): Promise { + if (!slices || slices.length === 0) return Promise.resolve(); + + const configById = new Map(deckSlicesConfig.map(c => [c.sliceId, c])); + + const autozoomSlices: { slice_id: number }[] = []; + const eagerSlices: { slice_id: number }[] = []; + const lazySlices: { slice_id: number }[] = []; + + slices.forEach(subslice => { + const config = configById.get(subslice.slice_id); + if (config?.lazyLoading) { + lazySlices.push(subslice); + } else if (resolveLayerAutozoom(config)) { + autozoomSlices.push(subslice); + } else { + eagerSlices.push(subslice); + } + }); + + // Async wrapper ensures synchronous throws from loadFn become rejected promises + const safeLoadFn = async ( + subslice: { slice_id: number }, + config: DeckSliceConfig | undefined, + ): Promise => callbacks.loadFn(subslice, config); + + // Phase 1: Load autozoom layers in parallel + const autozoomPromise = + autozoomSlices.length > 0 + ? Promise.all( + autozoomSlices.map(subslice => + safeLoadFn(subslice, configById.get(subslice.slice_id)), + ), + ).then(results => results.filter(e => e !== null) as TLayer[]) + : Promise.resolve([] as TLayer[]); + + return autozoomPromise.then(autozoomLayers => { + if (callbacks.isStale()) return undefined; + + callbacks.onAutozoomComplete(autozoomLayers); + + // Phase 2 + 3 run sequentially after autozoom completes + // eslint-disable-next-line consistent-return + return (async () => { + // Phase 2: Load remaining eager layers in parallel, append each as it finishes + if (eagerSlices.length > 0) { + const eagerResults = await Promise.all( + eagerSlices.map(subslice => + safeLoadFn(subslice, configById.get(subslice.slice_id)), + ), + ); + + for (const layerEntry of eagerResults) { + if (layerEntry && !callbacks.isStale()) { + callbacks.onEagerAppend(layerEntry); + } + } + } + + // Phase 3: Load lazy layers in batches of LAZY_BATCH_SIZE + for (let i = 0; i < lazySlices.length; i += LAZY_BATCH_SIZE) { + if (callbacks.isStale()) return; + + const batch = lazySlices.slice(i, i + LAZY_BATCH_SIZE); + // eslint-disable-next-line no-await-in-loop + const results = await Promise.all( + batch.map(subslice => + safeLoadFn(subslice, configById.get(subslice.slice_id)), + ), + ); + + for (const layerEntry of results) { + if (layerEntry && !callbacks.isStale()) { + callbacks.onLazyAppend(layerEntry); + } + } + } + })(); + }); +} diff --git a/superset-frontend/plugins/geoset-map-chart/src/components/MultiLegend.tsx b/superset-frontend/plugins/geoset-map-chart/src/components/MultiLegend.tsx index ee806c261b..f25d204bcd 100644 --- a/superset-frontend/plugins/geoset-map-chart/src/components/MultiLegend.tsx +++ b/superset-frontend/plugins/geoset-map-chart/src/components/MultiLegend.tsx @@ -1,4 +1,5 @@ import { styled } from '@superset-ui/core'; +import { Spin } from '@superset-ui/core/components/Spin'; import { useState, useEffect, useRef } from 'react'; import MapIcon from '@material-ui/icons/MapTwoTone'; import { RGBAColor } from '../utils/colors'; @@ -222,6 +223,15 @@ const LegendEntryContent: React.FC<{ onToggleVisibility, onToggleCategory, }) => { + if (legendEntry.loading) { + return ( + + +
{legendEntry.legendName}
+
+ ); + } + const { fill, stroke } = getDefaultColors(legendEntry); return ( @@ -482,26 +492,31 @@ export const MultiLegend: React.FC = ({ const isIndeterminate = someVisibleSomeNot || (isVisible && hasPartialCategories); + const allLoading = entries.every(e => e.legendEntry.loading); + return ( {/* Header */}
- {showGroupCheckboxes && ( - { - e.stopPropagation(); - setOptimisticVisibility(prev => ({ - ...prev, - ...Object.fromEntries( - allSliceIds.map(id => [id, !isVisible]), - ), - })); - onToggleLayerVisibility?.(allSliceIds); - }} - /> - )} + {showGroupCheckboxes && + (allLoading ? ( + + ) : ( + { + e.stopPropagation(); + setOptimisticVisibility(prev => ({ + ...prev, + ...Object.fromEntries( + allSliceIds.map(id => [id, !isVisible]), + ), + })); + onToggleLayerVisibility?.(allSliceIds); + }} + /> + ))} toggle(displayTitle, initialCollapsed)} > diff --git a/superset-frontend/plugins/geoset-map-chart/src/index.ts b/superset-frontend/plugins/geoset-map-chart/src/index.ts index 504da836be..fd614edf2b 100644 --- a/superset-frontend/plugins/geoset-map-chart/src/index.ts +++ b/superset-frontend/plugins/geoset-map-chart/src/index.ts @@ -22,3 +22,7 @@ import './utils/mapboxApi'; export { default as GeoSetMapChartPreset } from './preset'; export { default as GeoSetMapGeoJsonChartPlugin } from './layers/GeoSetLayer'; export { default as MultiChartPlugin } from './GeoSetMultiMap'; +export { + normalizeDeckSlices, + type DeckSliceConfig, +} from './GeoSetMultiMap/multiUtils'; diff --git a/superset-frontend/plugins/geoset-map-chart/src/types.ts b/superset-frontend/plugins/geoset-map-chart/src/types.ts index 555ad5024d..3a5ab44edd 100644 --- a/superset-frontend/plugins/geoset-map-chart/src/types.ts +++ b/superset-frontend/plugins/geoset-map-chart/src/types.ts @@ -103,6 +103,7 @@ export type LegendEntry = { sizeEntry?: SizeLegend; isCombinedMetricSize?: boolean; initialCollapsed?: boolean; // Whether this legend entry starts collapsed + loading?: boolean; // True for stub entries whose layer data is still loading }; export type LegendGroup = { diff --git a/superset-frontend/plugins/geoset-map-chart/test/GeoSetMultiMap/multiUtils.test.ts b/superset-frontend/plugins/geoset-map-chart/test/GeoSetMultiMap/multiUtils.test.ts new file mode 100644 index 0000000000..1b6c06cc8f --- /dev/null +++ b/superset-frontend/plugins/geoset-map-chart/test/GeoSetMultiMap/multiUtils.test.ts @@ -0,0 +1,675 @@ +/** + * 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, + loadLayersOrchestrated, + DeckSliceConfig, +} from '../../src/GeoSetMultiMap/multiUtils'; + +/** Helper: build a DeckSliceConfig with sensible defaults */ +const makeConfig = ( + sliceId: number, + overrides: Partial = {}, +): DeckSliceConfig => ({ + sliceId, + autozoom: true, + legendCollapsed: false, + initiallyHidden: false, + lazyLoading: false, + ...overrides, +}); + +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 false', () => { + expect( + resolveLayerAutozoom({ + sliceId: 1, + autozoom: false, + 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([]); + }); +}); + +describe('loadLayersOrchestrated', () => { + // ── Phase 1: Autozoom layers ────────────────────────────────────── + + it('loads autozoom layers in parallel and calls onAutozoomComplete', async () => { + const loadFn = jest.fn((subslice: { slice_id: number }) => + Promise.resolve(`layer-${subslice.slice_id}`), + ); + const onAutozoomComplete = jest.fn(); + const onEagerAppend = jest.fn(); + const onLazyAppend = jest.fn(); + + await loadLayersOrchestrated( + [{ slice_id: 1 }, { slice_id: 2 }], + [makeConfig(1), makeConfig(2)], + { + loadFn, + onAutozoomComplete, + onEagerAppend, + onLazyAppend, + isStale: () => false, + }, + ); + + expect(loadFn).toHaveBeenCalledTimes(2); + expect(onAutozoomComplete).toHaveBeenCalledWith(['layer-1', 'layer-2']); + expect(onEagerAppend).not.toHaveBeenCalled(); + expect(onLazyAppend).not.toHaveBeenCalled(); + }); + + it('filters null results from autozoom batch', async () => { + const loadFn = jest.fn((subslice: { slice_id: number }) => + subslice.slice_id === 1 + ? Promise.resolve(null) + : Promise.resolve(`layer-${subslice.slice_id}`), + ); + const onAutozoomComplete = jest.fn(); + + await loadLayersOrchestrated( + [{ slice_id: 1 }, { slice_id: 2 }], + [makeConfig(1), makeConfig(2)], + { + loadFn, + onAutozoomComplete, + onEagerAppend: jest.fn(), + onLazyAppend: jest.fn(), + isStale: () => false, + }, + ); + + expect(onAutozoomComplete).toHaveBeenCalledWith(['layer-2']); + }); + + it('calls onAutozoomComplete with empty array when no autozoom layers exist', async () => { + const onAutozoomComplete = jest.fn(); + const onEagerAppend = jest.fn(); + const onLazyAppend = jest.fn(); + + await loadLayersOrchestrated( + [{ slice_id: 1 }, { slice_id: 2 }], + [ + makeConfig(1, { autozoom: false }), + makeConfig(2, { lazyLoading: true }), + ], + { + loadFn: (subslice: { slice_id: number }) => + Promise.resolve(`layer-${subslice.slice_id}`), + onAutozoomComplete, + onEagerAppend, + onLazyAppend, + isStale: () => false, + }, + ); + + expect(onAutozoomComplete).toHaveBeenCalledWith([]); + expect(onEagerAppend).toHaveBeenCalledWith('layer-1'); + expect(onLazyAppend).toHaveBeenCalledWith('layer-2'); + }); + + // ── Phase 2: Eager (non-autozoom) layers ────────────────────────── + + it('loads eager non-autozoom layers in phase 2 after autozoom completes', async () => { + const callOrder: string[] = []; + const loadFn = jest.fn((subslice: { slice_id: number }) => { + callOrder.push(`load-${subslice.slice_id}`); + return Promise.resolve(`layer-${subslice.slice_id}`); + }); + const onAutozoomComplete = jest.fn(() => + callOrder.push('autozoom-complete'), + ); + const onEagerAppend = jest.fn((layer: string) => + callOrder.push(`eager-${layer}`), + ); + + await loadLayersOrchestrated( + [{ slice_id: 1 }, { slice_id: 2 }, { slice_id: 3 }], + [ + makeConfig(1, { autozoom: true }), + makeConfig(2, { autozoom: false }), + makeConfig(3, { autozoom: false }), + ], + { + loadFn, + onAutozoomComplete, + onEagerAppend, + onLazyAppend: jest.fn(), + isStale: () => false, + }, + ); + + expect(onAutozoomComplete).toHaveBeenCalledWith(['layer-1']); + expect(onEagerAppend).toHaveBeenCalledTimes(2); + + // Verify ordering: autozoom completes before eager loads start + const autozoomIdx = callOrder.indexOf('autozoom-complete'); + const firstEagerLoadIdx = callOrder.indexOf('load-2'); + expect(autozoomIdx).toBeLessThan(firstEagerLoadIdx); + }); + + it('skips null results from eager phase without aborting', async () => { + const loadFn = jest.fn((subslice: { slice_id: number }) => + subslice.slice_id === 2 + ? Promise.resolve(null) + : Promise.resolve(`layer-${subslice.slice_id}`), + ); + const onEagerAppend = jest.fn(); + + await loadLayersOrchestrated( + [{ slice_id: 1 }, { slice_id: 2 }, { slice_id: 3 }], + [ + makeConfig(1, { autozoom: true }), + makeConfig(2, { autozoom: false }), + makeConfig(3, { autozoom: false }), + ], + { + loadFn, + onAutozoomComplete: jest.fn(), + onEagerAppend, + onLazyAppend: jest.fn(), + isStale: () => false, + }, + ); + + // Slice 2 returned null — skipped, but slice 3 still appends + expect(onEagerAppend).toHaveBeenCalledTimes(1); + expect(onEagerAppend).toHaveBeenCalledWith('layer-3'); + }); + + // ── Phase 3: Lazy layers ────────────────────────────────────────── + + it('loads lazy layers in batches after eager layers', async () => { + const callOrder: string[] = []; + const loadFn = jest.fn((subslice: { slice_id: number }) => { + callOrder.push(`load-${subslice.slice_id}`); + return Promise.resolve(`layer-${subslice.slice_id}`); + }); + const onAutozoomComplete = jest.fn(() => + callOrder.push('autozoom-complete'), + ); + const onEagerAppend = jest.fn((layer: string) => + callOrder.push(`eager-${layer}`), + ); + const onLazyAppend = jest.fn((layer: string) => + callOrder.push(`lazy-${layer}`), + ); + + await loadLayersOrchestrated( + [{ slice_id: 1 }, { slice_id: 2 }, { slice_id: 3 }], + [ + makeConfig(1), + makeConfig(2, { lazyLoading: true }), + makeConfig(3, { lazyLoading: true }), + ], + { + loadFn, + onAutozoomComplete, + onEagerAppend, + onLazyAppend, + isStale: () => false, + }, + ); + + expect(onAutozoomComplete).toHaveBeenCalledWith(['layer-1']); + expect(onLazyAppend).toHaveBeenCalledTimes(2); + expect(onLazyAppend).toHaveBeenNthCalledWith(1, 'layer-2'); + expect(onLazyAppend).toHaveBeenNthCalledWith(2, 'layer-3'); + + // Verify ordering: autozoom completes before lazy loads start + const autozoomIdx = callOrder.indexOf('autozoom-complete'); + const firstLazyLoadIdx = callOrder.indexOf('load-2'); + expect(autozoomIdx).toBeLessThan(firstLazyLoadIdx); + }); + + it('respects batch size — batches do not overlap', async () => { + let inFlight = 0; + let maxInFlight = 0; + const loadFn = jest.fn( + (subslice: { slice_id: number }) => + new Promise(resolve => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + // Resolve async to let all concurrent calls register + setTimeout(() => { + inFlight -= 1; + resolve(`layer-${subslice.slice_id}`); + }, 0); + }), + ); + const onLazyAppend = jest.fn(); + + await loadLayersOrchestrated( + [ + { slice_id: 1 }, + { slice_id: 2 }, + { slice_id: 3 }, + { slice_id: 4 }, + { slice_id: 5 }, + ], + [ + makeConfig(1, { lazyLoading: true }), + makeConfig(2, { lazyLoading: true }), + makeConfig(3, { lazyLoading: true }), + makeConfig(4, { lazyLoading: true }), + makeConfig(5, { lazyLoading: true }), + ], + { + loadFn, + onAutozoomComplete: jest.fn(), + onEagerAppend: jest.fn(), + onLazyAppend, + isStale: () => false, + }, + ); + + // Batch size is 2, so max concurrent lazy loads should be 2 + expect(maxInFlight).toBeLessThanOrEqual(2); + expect(onLazyAppend).toHaveBeenCalledTimes(5); + }); + + it('handles all-lazy slices (no autozoom or eager phase)', async () => { + const loadFn = jest.fn((subslice: { slice_id: number }) => + Promise.resolve(`layer-${subslice.slice_id}`), + ); + const onAutozoomComplete = jest.fn(); + const onEagerAppend = jest.fn(); + const onLazyAppend = jest.fn(); + + await loadLayersOrchestrated( + [{ slice_id: 1 }, { slice_id: 2 }], + [ + makeConfig(1, { lazyLoading: true }), + makeConfig(2, { lazyLoading: true }), + ], + { + loadFn, + onAutozoomComplete, + onEagerAppend, + onLazyAppend, + isStale: () => false, + }, + ); + + // onAutozoomComplete still called with empty array + expect(onAutozoomComplete).toHaveBeenCalledWith([]); + expect(onEagerAppend).not.toHaveBeenCalled(); + expect(onLazyAppend).toHaveBeenCalledTimes(2); + expect(onLazyAppend).toHaveBeenNthCalledWith(1, 'layer-1'); + expect(onLazyAppend).toHaveBeenNthCalledWith(2, 'layer-2'); + }); + + // ── Three-phase ordering ────────────────────────────────────────── + + it('runs all three phases in order: autozoom → eager → lazy', async () => { + const callOrder: string[] = []; + const loadFn = jest.fn((subslice: { slice_id: number }) => { + callOrder.push(`load-${subslice.slice_id}`); + return Promise.resolve(`layer-${subslice.slice_id}`); + }); + + await loadLayersOrchestrated( + [{ slice_id: 1 }, { slice_id: 2 }, { slice_id: 3 }], + [ + makeConfig(1, { autozoom: true }), // phase 1: autozoom + makeConfig(2, { autozoom: false }), // phase 2: eager non-autozoom + makeConfig(3, { lazyLoading: true }), // phase 3: lazy + ], + { + loadFn, + onAutozoomComplete: () => callOrder.push('autozoom-complete'), + onEagerAppend: (layer: string) => callOrder.push(`eager-${layer}`), + onLazyAppend: (layer: string) => callOrder.push(`lazy-${layer}`), + isStale: () => false, + }, + ); + + // Verify strict phase ordering + const autozoomCompleteIdx = callOrder.indexOf('autozoom-complete'); + const eagerLoadIdx = callOrder.indexOf('load-2'); + const eagerAppendIdx = callOrder.indexOf('eager-layer-2'); + const lazyLoadIdx = callOrder.indexOf('load-3'); + + expect(autozoomCompleteIdx).toBeLessThan(eagerLoadIdx); + expect(eagerAppendIdx).toBeLessThan(lazyLoadIdx); + }); + + it('handles all-eager-no-autozoom slices', async () => { + const onAutozoomComplete = jest.fn(); + const onEagerAppend = jest.fn(); + const onLazyAppend = jest.fn(); + + await loadLayersOrchestrated( + [{ slice_id: 1 }, { slice_id: 2 }], + [ + makeConfig(1, { autozoom: false }), + makeConfig(2, { autozoom: false }), + ], + { + loadFn: (subslice: { slice_id: number }) => + Promise.resolve(`layer-${subslice.slice_id}`), + onAutozoomComplete, + onEagerAppend, + onLazyAppend, + isStale: () => false, + }, + ); + + expect(onAutozoomComplete).toHaveBeenCalledWith([]); + expect(onEagerAppend).toHaveBeenCalledTimes(2); + expect(onLazyAppend).not.toHaveBeenCalled(); + }); + + // ── Staleness ───────────────────────────────────────────────────── + + it('aborts when isStale returns true before autozoom phase completes', async () => { + let stale = false; + const loadFn = jest.fn(() => { + stale = true; + return Promise.resolve('layer'); + }); + const onAutozoomComplete = jest.fn(); + const onEagerAppend = jest.fn(); + + await loadLayersOrchestrated([{ slice_id: 1 }], [makeConfig(1)], { + loadFn, + onAutozoomComplete, + onEagerAppend, + onLazyAppend: jest.fn(), + isStale: () => stale, + }); + + expect(loadFn).toHaveBeenCalledTimes(1); + expect(onAutozoomComplete).not.toHaveBeenCalled(); + }); + + it('aborts lazy chain mid-way when isStale becomes true across batch boundaries', async () => { + let stale = false; + let lazyAppendCount = 0; + const loadFn = jest.fn((subslice: { slice_id: number }) => + Promise.resolve(`layer-${subslice.slice_id}`), + ); + const onAutozoomComplete = jest.fn(); + const onLazyAppend = jest.fn(() => { + lazyAppendCount += 1; + // Mark stale after the second lazy layer appends (end of first batch) + if (lazyAppendCount === 2) { + stale = true; + } + }); + + // 1 autozoom + 4 lazy = 2 lazy batches of size 2 + await loadLayersOrchestrated( + [ + { slice_id: 1 }, + { slice_id: 2 }, + { slice_id: 3 }, + { slice_id: 4 }, + { slice_id: 5 }, + ], + [ + makeConfig(1), + makeConfig(2, { lazyLoading: true }), + makeConfig(3, { lazyLoading: true }), + makeConfig(4, { lazyLoading: true }), + makeConfig(5, { lazyLoading: true }), + ], + { + loadFn, + onAutozoomComplete, + onEagerAppend: jest.fn(), + onLazyAppend, + isStale: () => stale, + }, + ); + + expect(onAutozoomComplete).toHaveBeenCalledWith(['layer-1']); + // First batch (slices 2 & 3) completes, then staleness aborts before second batch + expect(onLazyAppend).toHaveBeenCalledTimes(2); + expect(onLazyAppend).toHaveBeenNthCalledWith(1, 'layer-2'); + expect(onLazyAppend).toHaveBeenNthCalledWith(2, 'layer-3'); + }); + + // ── Null handling ───────────────────────────────────────────────── + + it('skips null results from lazy loadFn without aborting', async () => { + const loadFn = jest.fn((subslice: { slice_id: number }) => + subslice.slice_id === 2 + ? Promise.resolve(null) + : Promise.resolve(`layer-${subslice.slice_id}`), + ); + const onAutozoomComplete = jest.fn(); + const onLazyAppend = jest.fn(); + + await loadLayersOrchestrated( + [{ slice_id: 1 }, { slice_id: 2 }, { slice_id: 3 }], + [ + makeConfig(1), + makeConfig(2, { lazyLoading: true }), + makeConfig(3, { lazyLoading: true }), + ], + { + loadFn, + onAutozoomComplete, + onEagerAppend: jest.fn(), + onLazyAppend, + isStale: () => false, + }, + ); + + expect(onAutozoomComplete).toHaveBeenCalledWith(['layer-1']); + // Slice 2 returned null — skipped, but slice 3 still loads + expect(onLazyAppend).toHaveBeenCalledTimes(1); + expect(onLazyAppend).toHaveBeenCalledWith('layer-3'); + }); + + // ── Edge cases ──────────────────────────────────────────────────── + + it('resolves immediately for empty slices array', async () => { + const loadFn = jest.fn(); + const onAutozoomComplete = jest.fn(); + + await loadLayersOrchestrated([], [], { + loadFn, + onAutozoomComplete, + onEagerAppend: jest.fn(), + onLazyAppend: jest.fn(), + isStale: () => false, + }); + + expect(loadFn).not.toHaveBeenCalled(); + expect(onAutozoomComplete).not.toHaveBeenCalled(); + }); + + // ── Error propagation ───────────────────────────────────────────── + + it('rejects when an autozoom loadFn returns a rejected promise', async () => { + const loadFn = jest.fn(() => Promise.reject(new Error('network failure'))); + const onAutozoomComplete = jest.fn(); + + await expect( + loadLayersOrchestrated([{ slice_id: 1 }], [makeConfig(1)], { + loadFn, + onAutozoomComplete, + onEagerAppend: jest.fn(), + onLazyAppend: jest.fn(), + isStale: () => false, + }), + ).rejects.toThrow('network failure'); + + expect(onAutozoomComplete).not.toHaveBeenCalled(); + }); + + it('rejects when an eager loadFn returns a rejected promise', async () => { + const loadFn = jest.fn((subslice: { slice_id: number }) => + subslice.slice_id === 2 + ? Promise.reject(new Error('eager failure')) + : Promise.resolve(`layer-${subslice.slice_id}`), + ); + const onAutozoomComplete = jest.fn(); + const onEagerAppend = jest.fn(); + + await expect( + loadLayersOrchestrated( + [{ slice_id: 1 }, { slice_id: 2 }], + [makeConfig(1), makeConfig(2, { autozoom: false })], + { + loadFn, + onAutozoomComplete, + onEagerAppend, + onLazyAppend: jest.fn(), + isStale: () => false, + }, + ), + ).rejects.toThrow('eager failure'); + + expect(onAutozoomComplete).toHaveBeenCalledWith(['layer-1']); + expect(onEagerAppend).not.toHaveBeenCalled(); + }); + + it('rejects when a lazy loadFn returns a rejected promise', async () => { + const loadFn = jest.fn((subslice: { slice_id: number }) => + subslice.slice_id === 2 + ? Promise.reject(new Error('lazy failure')) + : Promise.resolve(`layer-${subslice.slice_id}`), + ); + const onAutozoomComplete = jest.fn(); + const onLazyAppend = jest.fn(); + + await expect( + loadLayersOrchestrated( + [{ slice_id: 1 }, { slice_id: 2 }], + [makeConfig(1), makeConfig(2, { lazyLoading: true })], + { + loadFn, + onAutozoomComplete, + onEagerAppend: jest.fn(), + onLazyAppend, + isStale: () => false, + }, + ), + ).rejects.toThrow('lazy failure'); + + expect(onAutozoomComplete).toHaveBeenCalledWith(['layer-1']); + expect(onLazyAppend).not.toHaveBeenCalled(); + }); + + it('rejects when loadFn throws synchronously', async () => { + const loadFn = jest.fn(() => { + throw new Error('sync throw'); + }); + const onAutozoomComplete = jest.fn(); + + await expect( + loadLayersOrchestrated([{ slice_id: 1 }], [makeConfig(1)], { + loadFn, + onAutozoomComplete, + onEagerAppend: jest.fn(), + onLazyAppend: jest.fn(), + isStale: () => false, + }), + ).rejects.toThrow('sync throw'); + + expect(onAutozoomComplete).not.toHaveBeenCalled(); + }); +}); diff --git a/superset-frontend/plugins/geoset-map-chart/test/components/MultiLegend.test.tsx b/superset-frontend/plugins/geoset-map-chart/test/components/MultiLegend.test.tsx index 6e9f9ad3d4..fa69fa9660 100644 --- a/superset-frontend/plugins/geoset-map-chart/test/components/MultiLegend.test.tsx +++ b/superset-frontend/plugins/geoset-map-chart/test/components/MultiLegend.test.tsx @@ -420,6 +420,73 @@ describe('MultiLegend', () => { }); }); + describe('optimistic visibility (no toggle lag)', () => { + it('group checkbox updates immediately on click without waiting for layerVisibility prop to change', () => { + const onToggle = jest.fn(); + renderWithTheme( + , + ); + userEvent.click(screen.getByText('Legend')); + const checkboxes = screen.getAllByRole('checkbox'); + // Group A checkbox starts checked + expect(checkboxes[0]).toBeChecked(); + // Click to toggle — layerVisibility prop does NOT change (simulates async parent update) + userEvent.click(checkboxes[0]); + // Checkbox should immediately reflect the toggled state via optimistic local state, + // without needing the parent to re-render with a new layerVisibility prop. + expect(checkboxes[0]).not.toBeChecked(); + expect(onToggle).toHaveBeenCalledWith(['1']); + }); + + it('group checkbox re-toggles correctly on second click', () => { + const onToggle = jest.fn(); + renderWithTheme( + , + ); + userEvent.click(screen.getByText('Legend')); + const checkboxes = screen.getAllByRole('checkbox'); + userEvent.click(checkboxes[0]); // toggle off + expect(checkboxes[0]).not.toBeChecked(); + userEvent.click(checkboxes[0]); // toggle back on + expect(checkboxes[0]).toBeChecked(); + expect(onToggle).toHaveBeenCalledTimes(2); + }); + }); + describe('legend entry content rendering', () => { it('renders swatch and legend name for simple entry', () => { renderWithTheme( diff --git a/superset-frontend/plugins/geoset-map-chart/test/layers/GeoSetLayer.test.ts b/superset-frontend/plugins/geoset-map-chart/test/layers/GeoSetLayer.test.ts index a5caade245..5280fff0db 100644 --- a/superset-frontend/plugins/geoset-map-chart/test/layers/GeoSetLayer.test.ts +++ b/superset-frontend/plugins/geoset-map-chart/test/layers/GeoSetLayer.test.ts @@ -25,6 +25,9 @@ import { getLayerStates, } from '../../src/layers/GeoSetLayer/GeoSetLayer'; +// Shared SVG icon mocks — see test/mocks/svgIcons.ts +import '../mocks/svgIcons'; + // ── DeckGL layer mocks ── // jest.mock factories can only reference variables prefixed with "mock". function mockMakeLayer(name: string) { @@ -82,9 +85,6 @@ jest.mock('../../src/utils/sandbox', () => ({ default: (code: string) => () => code, })); -// Shared SVG icon mocks — see test/mocks/svgIcons.ts -import '../mocks/svgIcons'; - jest.mock('../../src/utils/layerBuilders/buildTextOverlayLayer', () => ({ buildTextOverlayLayer: (opts: any) => ({ type: 'TextOverlayLayer', diff --git a/superset-frontend/src/explore/components/controls/DeckSlicesControl/index.tsx b/superset-frontend/src/explore/components/controls/DeckSlicesControl/index.tsx index b239593388..743fd93b6f 100644 --- a/superset-frontend/src/explore/components/controls/DeckSlicesControl/index.tsx +++ b/superset-frontend/src/explore/components/controls/DeckSlicesControl/index.tsx @@ -26,6 +26,10 @@ import { Popover } from '@superset-ui/core/components/Popover'; // eslint-disable-next-line no-restricted-imports import { Button } from '@superset-ui/core/components/Button'; import ControlHeader from 'src/explore/components/ControlHeader'; +import { + normalizeDeckSlices, + type DeckSliceConfig, +} from '@superset-ui/geoset-map-chart'; import { DragContainer, OptionControlContainer, @@ -41,13 +45,7 @@ interface DeckSliceOption { value: number; label: string; } - -export interface DeckSliceConfig { - sliceId: number; - autozoom: boolean; - legendCollapsed: boolean; - initiallyHidden: boolean; -} +export type { DeckSliceConfig }; export interface DeckSlicesControlProps { value: (DeckSliceConfig | number)[] | undefined; @@ -96,6 +94,7 @@ interface SliceSettings { autozoom: boolean; legendCollapsed: boolean; initiallyHidden: boolean; + lazyLoading: boolean; } interface SelectedSliceRowProps { @@ -104,6 +103,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 +166,7 @@ const SelectedSliceRow = ({ autozoom, legendCollapsed, initiallyHidden, + lazyLoading, index, onRemove, onMoveLabel, @@ -183,6 +184,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 +192,46 @@ const SelectedSliceRow = ({ setDraftAutozoom(autozoom); setDraftLegendCollapsed(legendCollapsed); setDraftInitiallyHidden(initiallyHidden); + setDraftLazyLoading(lazyLoading); + autozoomBeforeLazyRef.current = autozoom; } setSettingsOpen(open); }; + // Track the user's autozoom choice before lazy loading overrides it + const autozoomBeforeLazyRef = useRef(draftAutozoom); + + // When lazy loading is toggled on, auto-disable autozoom; + // when toggled off, restore autozoom to the user's prior draft value. + const handleToggleLazyLoading = () => { + const newLazyLoading = !draftLazyLoading; + setDraftLazyLoading(newLazyLoading); + if (newLazyLoading) { + autozoomBeforeLazyRef.current = draftAutozoom; + setDraftAutozoom(false); + } else { + setDraftAutozoom(autozoomBeforeLazyRef.current); + } + }; + // 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 +267,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')} + + + + + +