diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/buildQuery.ts b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/buildQuery.ts index 67b5ddd23274..0d24606515a1 100644 --- a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/buildQuery.ts +++ b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/buildQuery.ts @@ -33,6 +33,7 @@ export interface DeckGeoJsonFormData extends SqlaFormData { geojson?: string; filter_nulls?: boolean; js_columns?: string[]; + cross_filter_column?: string | null; tooltip_contents?: unknown[]; } @@ -41,6 +42,7 @@ export default function buildQuery(formData: DeckGeoJsonFormData) { geojson, filter_nulls = true, js_columns, + cross_filter_column, tooltip_contents, } = formData; @@ -61,6 +63,10 @@ export default function buildQuery(formData: DeckGeoJsonFormData) { const withJsColumns = addJsColumnsToColumns(columnStrings, js_columns); columns = withJsColumns as QueryFormColumn[]; + if (cross_filter_column && !columns.includes(cross_filter_column)) { + columns.push(cross_filter_column); + } + // Add tooltip columns columns = addTooltipColumnsToQuery(columns, tooltip_contents); diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/controlPanel.ts b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/controlPanel.ts index 6326968bad90..54138dc83360 100644 --- a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/controlPanel.ts +++ b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/controlPanel.ts @@ -44,6 +44,7 @@ import { tooltipContents, tooltipTemplate, jsFunctionControl, + crossFilterColumn, } from '../../utilities/Shared_DeckGL'; import { dndGeojsonColumn } from '../../utilities/sharedDndControls'; import { BLACK_COLOR } from '../../utilities/controls'; @@ -367,6 +368,7 @@ const config: ControlPanelConfig = { { label: t('Advanced'), controlSetRows: [ + [crossFilterColumn], [jsColumns], [jsDataMutator], [jsTooltip], diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/transformProps.ts b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/transformProps.ts index 572df299ade9..cfc5245de3fc 100644 --- a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/transformProps.ts +++ b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/transformProps.ts @@ -32,6 +32,7 @@ export default function transformProps(chartProps: ChartProps) { } const records = getRecordsFromQuery(chartProps.queriesData); + const crossFilterCol = formData.cross_filter_column || undefined; // Parse each record's geojson column value (replicates backend DeckGeoJson.get_properties) const features = records @@ -39,7 +40,17 @@ export default function transformProps(chartProps: ChartProps) { const geojsonStr = record[geojsonCol]; if (geojsonStr == null) return null; try { - return JSON.parse(String(geojsonStr)); + const feature = JSON.parse(String(geojsonStr)); + // Surface cross_filter_column from the row onto feature.properties so + // that picking can emit a dimension filter even when the GeoJSON blob + // doesn't carry the column itself. + if (crossFilterCol && record[crossFilterCol] !== undefined) { + feature.properties = { + ...feature.properties, + [crossFilterCol]: record[crossFilterCol], + }; + } + return feature; } catch { return null; } diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Polygon/buildQuery.ts b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Polygon/buildQuery.ts index ec319a9ae2c9..6e9bf5395bfd 100644 --- a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Polygon/buildQuery.ts +++ b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Polygon/buildQuery.ts @@ -47,6 +47,7 @@ export interface DeckPolygonFormData extends SqlaFormData { reverse_long_lat?: boolean; filter_nulls?: boolean; js_columns?: string[]; + cross_filter_column?: string | null; tooltip_contents?: unknown[]; tooltip_template?: string; } @@ -58,6 +59,7 @@ export default function buildQuery(formData: DeckPolygonFormData) { point_radius_fixed, filter_nulls = true, js_columns, + cross_filter_column, tooltip_contents, } = formData; @@ -78,6 +80,10 @@ export default function buildQuery(formData: DeckPolygonFormData) { } }); + if (cross_filter_column && !columns.includes(cross_filter_column)) { + columns.push(cross_filter_column); + } + columns = addTooltipColumnsToQuery(columns, tooltip_contents); const metrics = []; diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Polygon/controlPanel.ts b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Polygon/controlPanel.ts index 010e079d00f5..ba843d95f145 100644 --- a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Polygon/controlPanel.ts +++ b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Polygon/controlPanel.ts @@ -30,6 +30,7 @@ import { jsDataMutator, jsTooltip, jsOnclickHref, + crossFilterColumn, legendFormat, legendPosition, fillColorPicker, @@ -203,6 +204,7 @@ const config: ControlPanelConfig = { { label: t('Advanced'), controlSetRows: [ + [crossFilterColumn], [jsColumns], [jsDataMutator], [jsTooltip], diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/layers/common.tsx b/superset-frontend/plugins/preset-chart-deckgl/src/layers/common.tsx index 10064b64b144..6692565dd1ca 100644 --- a/superset-frontend/plugins/preset-chart-deckgl/src/layers/common.tsx +++ b/superset-frontend/plugins/preset-chart-deckgl/src/layers/common.tsx @@ -121,10 +121,21 @@ export function commonLayerProps({ formData, }); - if (event.leftButton && setDataMask !== undefined && crossFilters) { + // deck.gl v9 event shape: { type, offsetCenter, srcEvent, tapCount }. + // Older code checked event.leftButton / event.rightButton which no + // longer exist; dispatch on event.type and the underlying MouseEvent + // button instead. + const srcEvent = event?.srcEvent; + const isContextMenu = + event?.type === 'contextmenu' || srcEvent?.button === 2; + const isLeftClick = + event?.type === 'click' && (srcEvent?.button ?? 0) === 0; + + if (isLeftClick && setDataMask !== undefined && crossFilters) { setDataMask(crossFilters.dataMask); - } else if (event.rightButton && onContextMenu !== undefined) { - onContextMenu(event.center.x, event.center.y, { + } else if (isContextMenu && onContextMenu !== undefined) { + const center = event?.offsetCenter ?? event?.center ?? { x: 0, y: 0 }; + onContextMenu(center.x, center.y, { drillToDetail: [], crossFilter: crossFilters, drillBy: {}, diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx b/superset-frontend/plugins/preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx index 490e1367be40..6d74c1da1cee 100644 --- a/superset-frontend/plugins/preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx +++ b/superset-frontend/plugins/preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx @@ -181,6 +181,21 @@ export const jsColumns = { }, }; +export const crossFilterColumn: CustomControlItem = { + name: 'cross_filter_column', + config: { + ...sharedControls.groupby, + label: t('Cross-filter column'), + multi: false, + default: null, + description: t( + 'Dimension column emitted as a cross-filter when a feature is clicked. ' + + 'Other charts on the dashboard match against this column. If unset, ' + + 'falls back to the geometry column (legacy behavior, often unmatchable).', + ), + }, +}; + export const jsDataMutator = { name: 'js_data_mutator', config: jsFunctionControl( diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/utils/crossFiltersDataMask.test.ts b/superset-frontend/plugins/preset-chart-deckgl/src/utils/crossFiltersDataMask.test.ts index 4592bce5dd2c..d96e7986fee8 100644 --- a/superset-frontend/plugins/preset-chart-deckgl/src/utils/crossFiltersDataMask.test.ts +++ b/superset-frontend/plugins/preset-chart-deckgl/src/utils/crossFiltersDataMask.test.ts @@ -400,6 +400,293 @@ describe('getCrossFilterDataMask', () => { expect(dataMask).toStrictEqual(expected); }); + test('deck_polygon with cross_filter_column emits dimension filter (top-level)', () => { + const polygonFormData = { + ...formData, + line_column: 'geojson', + cross_filter_column: 'sa3_name', + }; + + const polygonPickingData = { + ...pickingData, + object: { + polygon: [ + [-122.42, 37.8], + [-122.42, 37.81], + [-122.41, 37.81], + [-122.41, 37.8], + [-122.42, 37.8], + ], + sa3_name: 'Christchurch West', + extraProps: {}, + }, + }; + + const dataMask = getCrossFilterDataMask({ + formData: polygonFormData, + data: polygonPickingData, + filterState: {}, + }); + + expect(dataMask).toStrictEqual({ + dataMask: { + extraFormData: { + filters: [ + { + col: 'sa3_name', + op: '==', + val: 'Christchurch West', + }, + ], + }, + filterState: { + value: ['Christchurch West'], + }, + }, + isCurrentValueSelected: false, + }); + }); + + test('deck_polygon with cross_filter_column reads from extraProps fallback', () => { + const polygonFormData = { + ...formData, + line_column: 'geojson', + cross_filter_column: 'region_id', + }; + + const polygonPickingData = { + ...pickingData, + object: { + polygon: [ + [-122.42, 37.8], + [-122.42, 37.81], + [-122.41, 37.81], + [-122.41, 37.8], + [-122.42, 37.8], + ], + extraProps: { region_id: 42 }, + }, + }; + + const dataMask = getCrossFilterDataMask({ + formData: polygonFormData, + data: polygonPickingData, + filterState: {}, + }); + + expect(dataMask).toStrictEqual({ + dataMask: { + extraFormData: { + filters: [ + { + col: 'region_id', + op: '==', + val: 42, + }, + ], + }, + filterState: { + value: [42], + }, + }, + isCurrentValueSelected: false, + }); + }); + + test('deck_polygon falls back to legacy when cross_filter_column value missing on feature', () => { + const polygonFormData = { + ...formData, + line_column: 'geojson', + cross_filter_column: 'sa3_name', + }; + + const polygonPickingData = { + ...pickingData, + object: { + polygon: 'POLYGON_PATH_STRING', + // sa3_name intentionally absent (e.g. chart was saved before the column + // was added to the query) + extraProps: {}, + }, + }; + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const dataMask = getCrossFilterDataMask({ + formData: polygonFormData, + data: polygonPickingData, + filterState: {}, + }); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + + expect(dataMask).toStrictEqual({ + dataMask: { + extraFormData: { + filters: [ + { + col: { + expressionType: 'SQL', + sqlExpression: "REPLACE(geojson, ' ', '')", + label: 'geojson', + }, + op: '==', + val: '"POLYGON_PATH_STRING"', + }, + ], + }, + filterState: { + value: ['"POLYGON_PATH_STRING"'], + }, + }, + isCurrentValueSelected: false, + }); + }); + + test('deck_polygon without cross_filter_column falls back to legacy geometry filter', () => { + const polygonFormData = { + ...formData, + line_column: 'geojson', + }; + + const polygonPickingData = { + ...pickingData, + object: { + polygon: 'POLYGON_PATH_STRING', + }, + }; + + const dataMask = getCrossFilterDataMask({ + formData: polygonFormData, + data: polygonPickingData, + filterState: {}, + }); + + expect(dataMask).toStrictEqual({ + dataMask: { + extraFormData: { + filters: [ + { + col: { + expressionType: 'SQL', + sqlExpression: "REPLACE(geojson, ' ', '')", + label: 'geojson', + }, + op: '==', + val: '"POLYGON_PATH_STRING"', + }, + ], + }, + filterState: { + value: ['"POLYGON_PATH_STRING"'], + }, + }, + isCurrentValueSelected: false, + }); + }); + + test('deck_geojson with cross_filter_column emits filter from feature.properties', () => { + const geojsonFormData = { + ...formData, + geojson: 'shape', + cross_filter_column: 'sa3_name', + }; + + const geojsonPickingData = { + ...pickingData, + object: { + type: 'Feature', + properties: { sa3_name: 'Christchurch West', sa3_code: '301' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-122.42, 37.8], + [-122.42, 37.81], + [-122.41, 37.81], + [-122.41, 37.8], + [-122.42, 37.8], + ], + ], + }, + }, + }; + + const dataMask = getCrossFilterDataMask({ + formData: geojsonFormData, + data: geojsonPickingData, + filterState: {}, + }); + + expect(dataMask).toStrictEqual({ + dataMask: { + extraFormData: { + filters: [ + { + col: 'sa3_name', + op: '==', + val: 'Christchurch West', + }, + ], + }, + filterState: { + value: ['Christchurch West'], + }, + }, + isCurrentValueSelected: false, + }); + }); + + test('deck_geojson without cross_filter_column falls back to legacy LIKE filter', () => { + const geojsonFormData = { + ...formData, + geojson: 'shape', + }; + + const coords = [ + [ + [-122.42, 37.8], + [-122.42, 37.81], + ], + ]; + + const geojsonPickingData = { + ...pickingData, + object: { + type: 'Feature', + properties: {}, + geometry: { type: 'Polygon', coordinates: coords }, + }, + }; + + const dataMask = getCrossFilterDataMask({ + formData: geojsonFormData, + data: geojsonPickingData, + filterState: {}, + }); + + expect(dataMask).toStrictEqual({ + dataMask: { + extraFormData: { + filters: [ + { + col: { + expressionType: 'SQL', + sqlExpression: "REPLACE(shape, ' ', '')", + label: 'shape', + }, + op: 'LIKE', + val: `%${JSON.stringify(coords)}%`, + }, + ], + }, + filterState: { + value: [coords], + }, + }, + isCurrentValueSelected: false, + }); + }); + test('handles Charts with GPU aggregation', () => { const latlongGPUFormData = { ...formData, diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/utils/crossFiltersDataMask.ts b/superset-frontend/plugins/preset-chart-deckgl/src/utils/crossFiltersDataMask.ts index 934d7cccdc8c..607301c758e4 100644 --- a/superset-frontend/plugins/preset-chart-deckgl/src/utils/crossFiltersDataMask.ts +++ b/superset-frontend/plugins/preset-chart-deckgl/src/utils/crossFiltersDataMask.ts @@ -54,6 +54,7 @@ export interface LayerFormData extends SqlaFormData { spatial?: SpatialData; line_column?: string; geojson?: string; + cross_filter_column?: string | null; } export interface FilterResult { @@ -408,11 +409,52 @@ const getLineColumnFilters = ({ data: PickingInfo; }): FilterResult => { const path = (data?.object?.path || data.object?.polygon) as string; - const val = JSON.stringify(path); if (!formData.line_column) throw new Error('Line column is required'); if (!path) throw new Error('Position of picked data is required'); + // Preferred path: emit on a dimension column the user selected. The value + // can land either directly on the picked feature (groupby/excluded keys are + // spread by addPropertiesToFeature) or under extraProps when it overlaps + // with js_columns (addJsColumnsToExtraProps). + if (formData.cross_filter_column) { + const col = formData.cross_filter_column; + const obj = data.object ?? {}; + const extraProps = (obj.extraProps ?? {}) as Record; + const dimensionVal = (obj[col] ?? extraProps[col]) as + | string + | number + | boolean + | null + | undefined; + + if (dimensionVal != null) { + return { + values: [dimensionVal as string | number], + filters: [ + { + col, + op: '==', + val: dimensionVal as string | number | boolean, + }, + ], + }; + } + // Value missing on the picked feature (e.g. column not in query yet + // because chart was saved pre-feature). Fall through to legacy path so + // the click still produces *some* filter rather than a silent error. + // eslint-disable-next-line no-console + console.warn( + `deck.gl cross-filter: column "${col}" not present on picked feature; ` + + `falling back to geometry filter. Re-save the chart to refresh its query.`, + ); + } + + // Legacy fallback: filter on the geometry column itself. This rarely matches + // anything stored as a full GeoJSON Feature; users should set + // cross_filter_column instead. + const val = JSON.stringify(path); + return { values: [val], filters: [ @@ -436,6 +478,40 @@ const getGeojsonFilters = ({ formData: LayerFormData; data: PickingInfo; }): FilterResult => { + // Preferred path: emit on a property of the picked GeoJSON Feature. + if (formData.cross_filter_column) { + const col = formData.cross_filter_column; + const properties = (data.object?.properties ?? {}) as Record< + string, + unknown + >; + const dimensionVal = properties[col] as + | string + | number + | boolean + | null + | undefined; + + if (dimensionVal != null) { + return { + values: [dimensionVal as string | number], + filters: [ + { + col, + op: '==', + val: dimensionVal as string | number | boolean, + }, + ], + }; + } + // eslint-disable-next-line no-console + console.warn( + `deck.gl cross-filter: column "${col}" not present on picked feature ` + + `properties; falling back to geometry filter.`, + ); + } + + // Legacy fallback: substring match against the stored geojson string. const geometry = data.object?.geometry?.coordinates; if (!geometry) throw new Error('Position of picked data is required');