From 2d7bdfb87c2cb8bda41bf3f1b1b52f14ec732bb1 Mon Sep 17 00:00:00 2001 From: alex-poor Date: Wed, 6 May 2026 21:58:41 +1200 Subject: [PATCH 1/2] fix(deckgl): emit usable cross-filter values from polygon and geojson clicks Adds a new optional `cross_filter_column` control to the deck_polygon and deck_geojson chart types. When set, clicking a feature emits a ` == ` filter (e.g. `sa3_name == 'Christchurch West'`) that other charts on the dashboard can match against by column name. Previously the emitted filter was a `REPLACE(, ' ', '') == ` SQL clause comparing a bare coordinate path string against the full stored GeoJSON Feature, which could never match in practice. Receiving charts saw "No data" and the dashboard chip showed a wall of polygon JSON. Polygon resolves the dimension value from the picked feature's top-level fields (groupby/columns spread by addPropertiesToFeature), falling back to extraProps for js_columns. Geojson reads from the picked Feature's properties object (the standard non-spatial location). The legacy behaviour is preserved as a fallback when cross_filter_column is unset so existing dashboards keep working. Also fixes a related click-handling regression: deck.gl v9 dropped event.leftButton/event.rightButton from the picking event. The commonLayerProps wiring was checking those undefined fields, so left clicks silently emitted nothing and right clicks never opened the context menu. Dispatch now uses event.type and srcEvent.button. Supersedes #28262. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/layers/Geojson/controlPanel.ts | 2 + .../src/layers/Polygon/buildQuery.ts | 6 + .../src/layers/Polygon/controlPanel.ts | 2 + .../preset-chart-deckgl/src/layers/common.tsx | 17 +- .../src/utilities/Shared_DeckGL.tsx | 15 + .../src/utils/crossFiltersDataMask.test.ts | 287 ++++++++++++++++++ .../src/utils/crossFiltersDataMask.ts | 78 ++++- 7 files changed, 403 insertions(+), 4 deletions(-) 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/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'); From cd83850aacf00772a12a1b27da7f405e047fdb4a Mon Sep 17 00:00:00 2001 From: alex-poor Date: Thu, 7 May 2026 08:09:24 +1200 Subject: [PATCH 2/2] fix(deckgl): wire cross_filter_column through deck_geojson query and transform Reviewers pointed out that the geojson control was wired up but the value was never hydrated onto picked features. Geojson/transformProps parses each row's geojson cell into a Feature and discards every other column, so when the cross-filter dimension lived in a sibling row column rather than inside the GeoJSON properties, the click handler never found it and silently fell back to the broken legacy filter. Add cross_filter_column to the queried columns in Geojson/buildQuery, and merge the row value into the parsed Feature's properties in Geojson/transformProps. Pre-existing properties on the GeoJSON Feature take a back seat; the row-level value wins so what the user picked in the control is what gets emitted. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/layers/Geojson/buildQuery.ts | 6 ++++++ .../src/layers/Geojson/transformProps.ts | 13 ++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) 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/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; }