diff --git a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/CountryMap.js b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/CountryMap.js index b1cf5016dcf7..3696042348b3 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/CountryMap.js +++ b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/CountryMap.js @@ -41,9 +41,17 @@ const propTypes = { linearColorScheme: PropTypes.string, mapBaseUrl: PropTypes.string, numberFormat: PropTypes.string, + onContextMenu: PropTypes.func, + emitCrossFilters: PropTypes.bool, + setDataMask: PropTypes.func, + filterState: PropTypes.object, + entity: PropTypes.string, + sliceId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), }; const maps = {}; +// Store zoom state per chart instance using element as key to enable garbage collection +const zoomStates = new WeakMap(); function CountryMap(element, props) { const { @@ -51,17 +59,24 @@ function CountryMap(element, props) { width, height, country, + entity, linearColorScheme, numberFormat, colorScheme, sliceId, + filterState, + emitCrossFilters, + onContextMenu, + setDataMask, } = props; const container = element; const format = getNumberFormatter(numberFormat); + const linearColorScale = getSequentialSchemeRegistry() .get(linearColorScheme) .createLinearScale(d3Extent(data, v => v.metric)); + const colorScale = CategoricalColorNamespace.getScale(colorScheme); const colorMap = {}; @@ -70,7 +85,12 @@ function CountryMap(element, props) { ? colorScale(d.country_id, sliceId) : linearColorScale(d.metric); }); - const colorFn = d => colorMap[d.properties.ISO] || 'none'; + + const colorFn = feature => { + if (!feature?.properties) return 'none'; + const iso = feature.properties.ISO; + return colorMap[iso] || '#d9d9d9'; + }; const path = d3.geo.path(); const div = d3.select(container); @@ -83,112 +103,214 @@ function CountryMap(element, props) { .attr('width', width) .attr('height', height) .attr('preserveAspectRatio', 'xMidYMid meet'); - const backgroundRect = svg - .append('rect') - .attr('class', 'background') - .attr('width', width) - .attr('height', height); + const g = svg.append('g'); const mapLayer = g.append('g').classed('map-layer', true); - const textLayer = g + const textLayer = svg .append('g') - .classed('text-layer', true) + .attr('class', 'text-layer') .attr('transform', `translate(${width / 2}, 45)`); - const bigText = textLayer.append('text').classed('big-text', true); + const bigText = textLayer + .append('text') + .classed('big-text', true) + .style('font-size', '18px'); const resultText = textLayer .append('text') .classed('result-text', true) - .attr('dy', '1em'); - - let centered; - - const clicked = function clicked(d) { - const hasCenter = d && centered !== d; - let x; - let y; - let k; - const halfWidth = width / 2; - const halfHeight = height / 2; - - if (hasCenter) { - const centroid = path.centroid(d); - [x, y] = centroid; - k = 4; - centered = d; - } else { - x = halfWidth; - y = halfHeight; - k = 1; - centered = null; - } + .attr('dy', '1em') + .style('font-size', '26px'); + + // Cross-filter support + const getCrossFilterDataMask = source => { + // Guard check for entity prop + if (!entity) return undefined; + + const selected = filterState?.selectedValues || []; + const iso = source?.properties?.ISO; + if (!iso) return undefined; + + const isSelected = selected.includes(iso); + const values = isSelected ? [] : [iso]; - g.transition() - .duration(750) - .attr( - 'transform', - `translate(${halfWidth},${halfHeight})scale(${k})translate(${-x},${-y})`, - ); - textLayer - .style('opacity', 0) - .attr( - 'transform', - `translate(0,0)translate(${x},${hasCenter ? y - 5 : 45})`, - ) - .transition() - .duration(750) - .style('opacity', 1); - bigText - .transition() - .duration(750) - .style('font-size', hasCenter ? 6 : 16); - resultText - .transition() - .duration(750) - .style('font-size', hasCenter ? 16 : 24); + 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 => { + const pointerEvent = d3.event; + + // Only prevent default if we have a context menu handler + if (typeof onContextMenu === 'function') { + pointerEvent?.preventDefault(); + } + + const iso = feature?.properties?.ISO; + if (!iso || typeof onContextMenu !== 'function' || !entity) return; - const selectAndDisplayNameOfRegion = function selectAndDisplayNameOfRegion( - feature, - ) { + 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 selectAndDisplayNameOfRegion = feature => { let name = ''; - if (feature && feature.properties) { - if (feature.properties.ID_2) { - name = feature.properties.NAME_2; - } else { - name = feature.properties.NAME_1; - } + if (feature?.properties) { + name = feature.properties.NAME_2 || feature.properties.NAME_1 || ''; } bigText.text(name); }; - const updateMetrics = function updateMetrics(region) { - if (region.length > 0) { - resultText.text(format(region[0].metric)); - } + const updateMetrics = regionRows => { + if (regionRows?.length > 0) resultText.text(format(regionRows[0].metric)); + else resultText.text(''); }; - const mouseenter = function mouseenter(d) { - // Darken color + const mouseenter = function (d) { let c = colorFn(d); - if (c !== 'none') { - c = d3.rgb(c).darker().toString(); - } + if (c && c !== 'none') c = d3.rgb(c).darker().toString(); d3.select(this).style('fill', c); selectAndDisplayNameOfRegion(d); - const result = data.filter( - region => region.country_id === d.properties.ISO, - ); + const result = data.filter(r => r.country_id === d?.properties?.ISO); updateMetrics(result); }; - const mouseout = function mouseout() { - d3.select(this).style('fill', colorFn); + const mouseout = function () { + d3.select(this).style('fill', d => colorFn(d)); bigText.text(''); resultText.text(''); }; + // Zoom with panning bounds + const zoom = d3.behavior + .zoom() + .scaleExtent([1, 4]) + .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); + + g.attr('transform', `translate(${tx}, ${ty}) scale(${scale})`); + // Prevent redundant writes by updating zoomStates only when scale or translate values change + const prev = zoomStates.get(element); + const changed = + !prev || + prev.scale !== scale || + prev.translate[0] !== tx || + prev.translate[1] !== ty; + if (changed) { + // Store zoom state using element as WeakMap key + zoomStates.set(element, { scale, translate: [tx, ty] }); + } + }); + + 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 = null) { + const selected = selectedValues || filterState?.selectedValues || []; + + mapLayer + .selectAll('path.region') + .style('fill-opacity', d => { + const iso = d?.properties?.ISO; + return selected.length === 0 || selected.includes(iso) ? 1 : 0.3; + }) + .style('stroke', d => { + const iso = d?.properties?.ISO; + return selected.includes(iso) ? '#222' : null; + }) + .style('stroke-width', d => { + const iso = d?.properties?.ISO; + return selected.includes(iso) ? '1.5px' : '0.5px'; + }); + } + + // Click handler + const handleClick = feature => { + // Guard checks for required props + if (!entity || !emitCrossFilters || typeof setDataMask !== 'function') { + return; + } + + const iso = feature?.properties?.ISO; + if (!iso) return; + + const baseline = filterState?.selectedValues || []; + const currently = new Set(baseline); + const shift = !!(d3.event && d3.event.shiftKey); + + if (shift) { + if (currently.has(iso)) { + currently.delete(iso); + } else { + currently.add(iso); + } + } else if (currently.size === 1 && currently.has(iso)) { + currently.clear(); + } else { + currently.clear(); + currently.add(iso); + } + + const newSelection = Array.from(currently); + + setDataMask({ + extraFormData: { + filters: newSelection.length + ? [{ col: entity, op: 'IN', val: newSelection }] + : [], + }, + filterState: { + value: newSelection.length ? newSelection : null, + selectedValues: newSelection.length ? newSelection : null, + }, + }); + + // Pass new selection directly to avoid stale state + highlightSelectedRegion(newSelection.length ? newSelection : []); + }; + function drawMap(mapData) { const { features } = mapData; const center = d3.geo.centroid(mapData); @@ -200,13 +322,11 @@ function CountryMap(element, props) { .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([ @@ -214,19 +334,24 @@ function CountryMap(element, props) { 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('d', path) .style('fill', colorFn) .on('mouseenter', mouseenter) .on('mouseout', mouseout) - .on('click', clicked); + .on('contextmenu', handleContextMenu) + .on('click', handleClick); + + mapLayer.selectAll('path.region').attr('d', path).style('fill', colorFn); + sel.exit().remove(); + + highlightSelectedRegion(); } const map = maps[country]; diff --git a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/index.js b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/index.js index ad4ed7c79729..95d252a7994c 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/index.js +++ b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/index.js @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core'; +import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core'; import transformProps from './transformProps'; import exampleUsa from './images/exampleUsa.jpg'; import exampleUsaDark from './images/exampleUsa-dark.jpg'; @@ -48,6 +48,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.js b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/transformProps.js index 8789c3d2f34f..04cba9f8e8c8 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-country-map/src/transformProps.js +++ b/superset-frontend/plugins/legacy-plugin-chart-country-map/src/transformProps.js @@ -17,8 +17,18 @@ * under the License. */ export default function transformProps(chartProps) { - const { width, height, formData, queriesData } = chartProps; const { + width, + height, + formData, + queriesData, + hooks, + inContextMenu, + filterState, + emitCrossFilters, + } = chartProps; + const { + entity, linearColorScheme, numberFormat, selectCountry, @@ -26,6 +36,8 @@ export default function transformProps(chartProps) { sliceId, } = formData; + const { onContextMenu, setDataMask } = hooks; + return { width, height, @@ -35,5 +47,11 @@ export default function transformProps(chartProps) { numberFormat, colorScheme, sliceId, + entity, + onContextMenu, + inContextMenu, + setDataMask, + emitCrossFilters, + filterState, }; }