From 6bc77fecc2bbc3f1ec8bb18a388fecf892885751 Mon Sep 17 00:00:00 2001 From: Imad Helal Date: Wed, 24 Jun 2026 09:54:47 +0200 Subject: [PATCH] feat(country-map): add cross-filters support (#35859) Co-authored-by: Superset Dev Co-authored-by: Claude Opus 4.7 Co-authored-by: Amin Ghadersohi Co-authored-by: Evan Rusackas --- .../src/CountryMap.ts | 294 ++++++++++++++---- .../src/index.ts | 7 +- .../src/transformProps.ts | 19 +- .../test/CountryMap.test.tsx | 97 +++++- .../test/transformProps.test.ts | 76 +++++ 5 files changed, 430 insertions(+), 63 deletions(-) create mode 100644 superset-frontend/plugins/legacy-plugin-chart-country-map/test/transformProps.test.ts diff --git a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/CountryMap.ts b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/CountryMap.ts index c83e1872b8d..a8bbb04d0ac 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/CountryMap.ts +++ b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/CountryMap.ts @@ -21,10 +21,12 @@ import d3 from 'd3'; import { extent as d3Extent } from 'd3-array'; import { - ValueFormatter, - getNumberFormatter, - getSequentialSchemeRegistry, + BinaryQueryObjectFilterClause, CategoricalColorNamespace, + ContextMenuFilters, + DataMask, + ValueFormatter, + getSequentialSchemeRegistry, } from '@superset-ui/core'; import countries, { countryOptions } from './countries'; @@ -65,9 +67,28 @@ interface CountryMapProps { formatter: ValueFormatter; colorScheme: string; sliceId: number; + onContextMenu?: ( + clientX: number, + clientY: number, + data: ContextMenuFilters, + ) => void; + emitCrossFilters?: boolean; + setDataMask?: (dataMask: DataMask) => void; + filterState?: { + selectedValues?: string[]; + extraFormData?: { + filters?: BinaryQueryObjectFilterClause[]; + }; + }; + entity?: string; } const maps: Record = {}; +// Store zoom state per chart instance using element as key to enable garbage collection +const zoomStates = new WeakMap< + HTMLElement, + { scale: number; translate: [number, number] } +>(); function CountryMap(element: HTMLElement, props: CountryMapProps) { const { @@ -75,10 +96,15 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) { width, height, country, + entity, linearColorScheme, formatter, colorScheme, sliceId, + filterState, + emitCrossFilters, + onContextMenu, + setDataMask, } = props; const container = element; @@ -99,7 +125,15 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) { ? colorScale(d.country_id, sliceId) : (linearColorScale(d.metric) ?? ''); }); - const colorFn = (d: GeoFeature) => colorMap[d.properties.ISO] || 'none'; + + const colorFn = (feature: GeoFeature): string => { + if (!feature?.properties) return '#d9d9d9'; + const iso = feature.properties.ISO; + return colorMap[iso] || '#d9d9d9'; + }; + + // Check if dashboard is in edit mode + const isEditMode = container.closest('.dashboard--editing') !== null; const path = d3.geo.path(); const div = d3.select(container); @@ -112,6 +146,11 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) { .attr('width', width) .attr('height', height) .attr('preserveAspectRatio', 'xMidYMid meet'); + + // Only set grab cursor if not in edit mode + if (!isEditMode) { + svg.style('cursor', 'grab'); + } const backgroundRect = svg .append('rect') .attr('class', 'background') @@ -119,39 +158,64 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) { .attr('height', height); const g = svg.append('g'); const mapLayer = g.append('g').classed('map-layer', true); + // Add hover popup for tooltip const hoverPopup = div.append('div').attr('class', 'hover-popup'); - let centered: GeoFeature | null; + // Track mouse position to distinguish clicks from drags + let mousedownPos: { x: number; y: number } | null = null; - const clicked = function clicked(d: GeoFeature) { - const hasCenter = d && centered !== d; - let x: number; - let y: number; - let k: number; - const halfWidth = width / 2; - const halfHeight = height / 2; + // Cross-filter support + const getCrossFilterDataMask = ( + source: GeoFeature, + ): { dataMask: DataMask; isCurrentValueSelected: boolean } | undefined => { + if (!entity) return undefined; - if (hasCenter) { - const centroid = path.centroid(d); - [x, y] = centroid; - k = 4; - centered = d; - } else { - x = halfWidth; - y = halfHeight; - k = 1; - centered = null; - } + const selected = filterState?.selectedValues || []; + const iso = source?.properties?.ISO; + if (!iso) return undefined; - g.transition() - .duration(750) - .attr( - 'transform', - `translate(${halfWidth},${halfHeight})scale(${k})translate(${-x},${-y})`, - ); + const isSelected = selected.includes(iso); + const values = isSelected ? [] : [iso]; + + return { + dataMask: { + extraFormData: { + filters: values.length + ? [{ col: entity, op: 'IN', val: values }] + : [], + }, + filterState: { + value: values.length ? values : null, + selectedValues: values.length ? values : null, + }, + }, + isCurrentValueSelected: isSelected, + }; }; - backgroundRect.on('click', clicked); + // Handle right-click context menu + const handleContextMenu = (feature: GeoFeature): void => { + const pointerEvent = d3.event; + + if (typeof onContextMenu === 'function') { + pointerEvent?.preventDefault(); + } + + const iso = feature?.properties?.ISO; + if (!iso || typeof onContextMenu !== 'function' || !entity) return; + + const drillVal = iso; + const drillToDetailFilters = [ + { col: entity, op: '==', val: drillVal, formattedVal: drillVal }, + ]; + const drillByFilters = [{ col: entity, op: '==', val: drillVal }]; + + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { + drillToDetail: drillToDetailFilters, + crossFilter: getCrossFilterDataMask(feature), + drillBy: { filters: drillByFilters, groupbyFieldName: 'entity' }, + }); + }; const getNameOfRegion = function getNameOfRegion( feature: GeoFeature, @@ -165,7 +229,7 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) { return ''; }; - const updatePopupPosition = () => { + const updatePopupPosition = (): void => { const svgHeight = svg.node().getBoundingClientRect().height; const [x, y] = d3.mouse(svg.node()); hoverPopup @@ -175,36 +239,135 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) { .classed('popup-at-bottom', y > (svgHeight * 2) / 3); }; - const mouseenter = function mouseenter(this: SVGPathElement, d: GeoFeature) { + const mouseenter = function mouseenter( + this: SVGPathElement, + d: GeoFeature, + ): void { // Darken color let c: string = colorFn(d); - if (c !== 'none') { + if (c) { c = d3.rgb(c).darker().toString(); } d3.select(this).style('fill', c); - // Display information popup - const result = data.filter( - region => region.country_id === d.properties.ISO, - ); + // Display information popup + const result = data.filter(r => r.country_id === d?.properties?.ISO); + const regionName = escapeHtml(getNameOfRegion(d)); + const metricValue = + result.length > 0 ? escapeHtml(String(formatter(result[0].metric))) : ''; hoverPopup .style('display', 'block') - .html( - `
${getNameOfRegion(d)}
${result.length > 0 ? formatter(result[0].metric) : ''}
`, - ); + .html(`
${regionName}
${metricValue}
`); updatePopupPosition(); }; - const mousemove = function mousemove() { + // Mouse move handler to update tooltip position + const mousemove = function mousemove(): void { updatePopupPosition(); }; - const mouseout = function mouseout(this: SVGPathElement) { - d3.select(this).style('fill', colorFn); + const mouseout = function mouseout(this: SVGPathElement): void { + d3.select(this).style('fill', (d: GeoFeature) => colorFn(d)); hoverPopup.style('display', 'none'); }; - function drawMap(mapData: GeoData) { + // Only enable zoom if not in edit mode + if (!isEditMode) { + // Zoom with panning bounds + const zoom = d3.behavior + .zoom() + .scaleExtent([1, 4]) + .on('zoomstart', () => { + svg.style('cursor', 'grabbing'); + }) + .on('zoom', () => { + const { translate, scale } = d3.event; + let [tx, ty] = translate; + + const scaledW = width * scale; + const scaledH = height * scale; + const minX = Math.min(0, width - scaledW); + const maxX = 0; + const minY = Math.min(0, height - scaledH); + const maxY = 0; + + tx = Math.max(Math.min(tx, maxX), minX); + ty = Math.max(Math.min(ty, maxY), minY); + + // Sync D3's internal translate state with the clamped values so the + // next wheel/zoom event starts from the constrained position rather + // than the unclamped one (otherwise the view jumps). + zoom.translate([tx, ty]); + + g.attr('transform', `translate(${tx}, ${ty}) scale(${scale})`); + const prev = zoomStates.get(element); + const changed = + !prev || + prev.scale !== scale || + prev.translate[0] !== tx || + prev.translate[1] !== ty; + if (changed) { + zoomStates.set(element, { scale, translate: [tx, ty] }); + } + }) + .on('zoomend', () => { + svg.style('cursor', 'grab'); + }); + + d3.select(svg.node()).call(zoom); + + // Restore previous zoom state if it exists + const savedZoom = zoomStates.get(element); + if (savedZoom) { + const { scale, translate } = savedZoom; + zoom.scale(scale).translate(translate); + g.attr( + 'transform', + `translate(${translate[0]}, ${translate[1]}) scale(${scale})`, + ); + } + } + + // Visual highlighting for selected regions + function highlightSelectedRegion( + selectedValues: string[] | null = null, + ): void { + const selected = selectedValues || filterState?.selectedValues || []; + + mapLayer + .selectAll('path.region') + .style('fill-opacity', (d: GeoFeature) => { + const iso = d?.properties?.ISO; + return selected.length === 0 || selected.includes(iso) ? 1 : 0.3; + }) + .style('stroke', (d: GeoFeature) => { + const iso = d?.properties?.ISO; + return selected.includes(iso) ? '#222' : null; + }) + .style('stroke-width', (d: GeoFeature) => { + const iso = d?.properties?.ISO; + return selected.includes(iso) ? '1.5px' : '0.5px'; + }); + } + + // Click handler for cross-filters + const handleClick = (feature: GeoFeature): void => { + if (!entity || !emitCrossFilters || typeof setDataMask !== 'function') { + return; + } + + const result = getCrossFilterDataMask(feature); + if (!result) return; + + const { dataMask, isCurrentValueSelected } = result; + setDataMask(dataMask); + + const iso = feature?.properties?.ISO; + const newSelection = isCurrentValueSelected || !iso ? [] : [iso]; + highlightSelectedRegion(newSelection); + }; + + function drawMap(mapData: GeoData): void { const { features } = mapData; const center = d3.geo.centroid(mapData); const scale = 100; @@ -215,13 +378,11 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) { .translate([width / 2, height / 2]); path.projection(projection); - // Compute scale that fits container. const bounds = path.bounds(mapData); const hscale = (scale * width) / (bounds[1][0] - bounds[0][0]); const vscale = (scale * height) / (bounds[1][1] - bounds[0][1]); - const newScale = hscale < vscale ? hscale : vscale; + const newScale = Math.min(hscale, vscale); - // Compute bounds and offset using the updated scale. projection.scale(newScale); const newBounds = path.bounds(mapData); projection.translate([ @@ -229,20 +390,45 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) { height - (newBounds[0][1] + newBounds[1][1]) / 2, ]); - // Draw each province as a path - mapLayer - .selectAll('path') - .data(features) + const sel = mapLayer.selectAll('path.region').data(features); + + sel .enter() .append('path') - .attr('d', path) .attr('class', 'region') - .attr('vector-effect', 'non-scaling-stroke') + .attr('vector-effect', 'non-scaling-stroke'); + + // Apply attributes and event handlers to all elements (enter + update) + mapLayer + .selectAll('path.region') + .attr('d', path) .style('fill', colorFn) .on('mouseenter', mouseenter) .on('mousemove', mousemove) .on('mouseout', mouseout) - .on('click', clicked); + .on('contextmenu', handleContextMenu) + .on('mousedown', function mousedown() { + const pos = d3.mouse(svg.node()); + mousedownPos = { x: pos[0], y: pos[1] }; + }) + .on('click', function click(feature: GeoFeature) { + if (mousedownPos) { + const pos = d3.mouse(svg.node()); + const dx = Math.abs(pos[0] - mousedownPos.x); + const dy = Math.abs(pos[1] - mousedownPos.y); + const dragThreshold = 5; + + if (dx < dragThreshold && dy < dragThreshold) { + handleClick(feature); + } + + mousedownPos = null; + } + }); + + sel.exit().remove(); + + highlightSelectedRegion(); } const map = maps[country]; diff --git a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/index.ts b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/index.ts index e59be4ec4b0..8da3e8e6ae3 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/index.ts +++ b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/index.ts @@ -17,7 +17,7 @@ * under the License. */ import { t } from '@apache-superset/core/translation'; -import { ChartMetadata, ChartPlugin } from '@superset-ui/core'; +import { ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core'; import transformProps from './transformProps'; import exampleUsa from './images/exampleUsa.jpg'; import exampleUsaDark from './images/exampleUsa-dark.jpg'; @@ -49,6 +49,11 @@ const metadata = new ChartMetadata({ thumbnail, thumbnailDark, useLegacyApi: true, + behaviors: [ + Behavior.InteractiveChart, + Behavior.DrillToDetail, + Behavior.DrillBy, + ], }); export default class CountryMapChartPlugin extends ChartPlugin { diff --git a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/transformProps.ts b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/transformProps.ts index af32c4ef46d..f4ee4728d1f 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/transformProps.ts +++ b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/transformProps.ts @@ -19,8 +19,18 @@ import { ChartProps, getValueFormatter } from '@superset-ui/core'; export default function transformProps(chartProps: ChartProps) { - const { width, height, formData, queriesData, datasource } = chartProps; const { + width, + height, + formData, + queriesData, + datasource, + hooks = {}, + filterState, + emitCrossFilters, + } = chartProps; + const { + entity, linearColorScheme, numberFormat, currencyFormat, @@ -49,6 +59,8 @@ export default function transformProps(chartProps: ChartProps) { detectedCurrency, ); + const { onContextMenu, setDataMask } = hooks; + return { width, height, @@ -59,5 +71,10 @@ export default function transformProps(chartProps: ChartProps) { colorScheme, sliceId, formatter, + entity, + onContextMenu, + setDataMask, + emitCrossFilters, + filterState, }; } diff --git a/superset-frontend/plugins/legacy-plugin-chart-country-map/test/CountryMap.test.tsx b/superset-frontend/plugins/legacy-plugin-chart-country-map/test/CountryMap.test.tsx index c12fe2b82a6..c6d9b1ce501 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-country-map/test/CountryMap.test.tsx +++ b/superset-frontend/plugins/legacy-plugin-chart-country-map/test/CountryMap.test.tsx @@ -133,10 +133,11 @@ describe('CountryMap (legacy d3)', () => { expect(popup!).toHaveStyle({ display: 'none' }); }); - test('shows tooltip on mouseenter/mousemove/mouseout', async () => { + test('emits a cross-filter data mask when a region is clicked', () => { d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) => cb(null, mockMapData), ); + const setDataMask = jest.fn(); render( { linearColorScheme="bnbColors" colorScheme="" formatter={jest.fn().mockReturnValue('100')} + entity="country_code" + emitCrossFilters + setDataMask={setDataMask} + filterState={{ selectedValues: [] }} />, ); const region = document.querySelector('path.region'); expect(region).not.toBeNull(); - const popup = document.querySelector('.hover-popup'); - expect(popup).not.toBeNull(); + // A click is only treated as a selection when it follows a mousedown + // without dragging beyond the threshold (d3.mouse is mocked to a fixed + // position, so the down/up positions match). + fireEvent.mouseDown(region!); + fireEvent.click(region!); - fireEvent.mouseEnter(region!); - expect(popup!).toHaveStyle({ display: 'block' }); + expect(setDataMask).toHaveBeenCalledTimes(1); + expect(setDataMask).toHaveBeenCalledWith( + expect.objectContaining({ + extraFormData: { + filters: [{ col: 'country_code', op: 'IN', val: ['CAN'] }], + }, + filterState: expect.objectContaining({ value: ['CAN'] }), + }), + ); + }); - fireEvent.mouseOut(region!); - expect(popup!).toHaveStyle({ display: 'none' }); + test('does not emit a cross-filter when emitCrossFilters is disabled', () => { + d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) => + cb(null, mockMapData), + ); + const setDataMask = jest.fn(); + + render( + , + ); + + const region = document.querySelector('path.region'); + fireEvent.mouseDown(region!); + fireEvent.click(region!); + + expect(setDataMask).not.toHaveBeenCalled(); + }); + + test('opens the context menu with drill-by keyed on the entity control', () => { + d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) => + cb(null, mockMapData), + ); + const onContextMenu = jest.fn(); + + render( + , + ); + + const region = document.querySelector('path.region'); + expect(region).not.toBeNull(); + + fireEvent.contextMenu(region!, { clientX: 123, clientY: 45 }); + + expect(onContextMenu).toHaveBeenCalledTimes(1); + const [[clientX, clientY, payload]] = onContextMenu.mock.calls; + expect(clientX).toBe(123); + expect(clientY).toBe(45); + expect(payload.drillToDetail).toEqual([ + { col: 'country_code', op: '==', val: 'CAN', formattedVal: 'CAN' }, + ]); + // groupbyFieldName must be the form-data control key ('entity'), not the + // selected column value ('country_code'), so DrillByModal can map the + // selection back to the chart control. + expect(payload.drillBy).toEqual({ + filters: [{ col: 'country_code', op: '==', val: 'CAN' }], + groupbyFieldName: 'entity', + }); }); }); diff --git a/superset-frontend/plugins/legacy-plugin-chart-country-map/test/transformProps.test.ts b/superset-frontend/plugins/legacy-plugin-chart-country-map/test/transformProps.test.ts new file mode 100644 index 00000000000..2ff9a593922 --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-country-map/test/transformProps.test.ts @@ -0,0 +1,76 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { ChartProps } from '@superset-ui/core'; +import transformProps from '../src/transformProps'; + +const onContextMenu = jest.fn(); +const setDataMask = jest.fn(); + +const createProps = (formDataOverrides = {}, chartPropsOverrides = {}) => + ({ + width: 800, + height: 600, + formData: { + entity: 'country_code', + linearColorScheme: 'bnbColors', + numberFormat: '.2f', + selectCountry: 'France', + colorScheme: '', + sliceId: 1, + metric: 'count', + ...formDataOverrides, + }, + queriesData: [{ data: [{ country_id: 'FRA', metric: 10 }] }], + datasource: { currencyFormats: {}, columnFormats: {} }, + hooks: { onContextMenu, setDataMask }, + filterState: { selectedValues: ['FRA'] }, + emitCrossFilters: true, + ...chartPropsOverrides, + }) as unknown as ChartProps; + +test('forwards cross-filter hooks and state to the chart', () => { + const transformed = transformProps(createProps()); + + expect(transformed).toMatchObject({ + width: 800, + height: 600, + entity: 'country_code', + onContextMenu, + setDataMask, + emitCrossFilters: true, + filterState: { selectedValues: ['FRA'] }, + data: [{ country_id: 'FRA', metric: 10 }], + }); +}); + +test('lowercases the selected country for map lookup', () => { + const transformed = transformProps(createProps()); + expect(transformed.country).toBe('france'); +}); + +test('passes a null country when none is selected', () => { + const transformed = transformProps(createProps({ selectCountry: undefined })); + expect(transformed.country).toBeNull(); +}); + +test('defaults hooks to an empty object when none are provided', () => { + const transformed = transformProps(createProps({}, { hooks: undefined })); + expect(transformed.onContextMenu).toBeUndefined(); + expect(transformed.setDataMask).toBeUndefined(); +});