mirror of
https://github.com/apache/superset.git
synced 2026-04-09 19:35:21 +00:00
286 lines
7.4 KiB
JavaScript
286 lines
7.4 KiB
JavaScript
/**
|
|
* 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.
|
|
*/
|
|
/* eslint-disable react/sort-prop-types */
|
|
import d3 from 'd3';
|
|
import PropTypes from 'prop-types';
|
|
import { extent as d3Extent } from 'd3-array';
|
|
import {
|
|
getSequentialSchemeRegistry,
|
|
CategoricalColorNamespace,
|
|
} from '@superset-ui/core';
|
|
import Datamap from 'datamaps/dist/datamaps.all.min';
|
|
import { ColorBy } from './utils';
|
|
|
|
const propTypes = {
|
|
data: PropTypes.arrayOf(
|
|
PropTypes.shape({
|
|
country: PropTypes.string,
|
|
code: PropTypes.string,
|
|
latitude: PropTypes.number,
|
|
longitude: PropTypes.number,
|
|
name: PropTypes.string,
|
|
m1: PropTypes.number,
|
|
m2: PropTypes.number,
|
|
}),
|
|
),
|
|
height: PropTypes.number,
|
|
maxBubbleSize: PropTypes.number,
|
|
showBubbles: PropTypes.bool,
|
|
linearColorScheme: PropTypes.string,
|
|
color: PropTypes.string,
|
|
colorScheme: PropTypes.string,
|
|
setDataMask: PropTypes.func,
|
|
onContextMenu: PropTypes.func,
|
|
emitCrossFilters: PropTypes.bool,
|
|
formatter: PropTypes.object,
|
|
};
|
|
|
|
function WorldMap(element, props) {
|
|
const {
|
|
countryFieldtype,
|
|
entity,
|
|
data,
|
|
width,
|
|
height,
|
|
maxBubbleSize,
|
|
showBubbles,
|
|
linearColorScheme,
|
|
color,
|
|
colorBy,
|
|
colorScheme,
|
|
sliceId,
|
|
theme,
|
|
onContextMenu,
|
|
setDataMask,
|
|
inContextMenu,
|
|
filterState,
|
|
emitCrossFilters,
|
|
formatter,
|
|
} = props;
|
|
const div = d3.select(element);
|
|
div.classed('superset-legacy-chart-world-map', true);
|
|
div.selectAll('*').remove();
|
|
|
|
// Ignore XXX's to get better normalization
|
|
const filteredData = data.filter(d => d.country && d.country !== 'XXX');
|
|
|
|
const extRadius = d3.extent(filteredData, d => Math.sqrt(d.m2));
|
|
const radiusScale = d3.scale
|
|
.linear()
|
|
.domain([extRadius[0], extRadius[1]])
|
|
.range([1, maxBubbleSize]);
|
|
|
|
let processedData;
|
|
let colorFn;
|
|
if (colorBy === ColorBy.Country) {
|
|
colorFn = CategoricalColorNamespace.getScale(colorScheme);
|
|
|
|
processedData = filteredData.map(d => ({
|
|
...d,
|
|
radius: radiusScale(Math.sqrt(d.m2)),
|
|
fillColor: colorFn(d.name, sliceId),
|
|
}));
|
|
} else {
|
|
colorFn = getSequentialSchemeRegistry()
|
|
.get(linearColorScheme)
|
|
.createLinearScale(d3Extent(filteredData, d => d.m1));
|
|
|
|
processedData = filteredData.map(d => ({
|
|
...d,
|
|
radius: radiusScale(Math.sqrt(d.m2)),
|
|
fillColor: colorFn(d.m1),
|
|
}));
|
|
}
|
|
|
|
const mapData = {};
|
|
processedData.forEach(d => {
|
|
mapData[d.country] = d;
|
|
});
|
|
|
|
const getCrossFilterDataMask = source => {
|
|
const selected = Object.values(filterState.selectedValues || {});
|
|
const key = source.id || source.country;
|
|
const country =
|
|
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.code;
|
|
|
|
if (!country) {
|
|
return undefined;
|
|
}
|
|
|
|
let values;
|
|
if (selected.includes(key)) {
|
|
values = [];
|
|
} else {
|
|
values = [country];
|
|
}
|
|
|
|
return {
|
|
dataMask: {
|
|
extraFormData: {
|
|
filters: values.length
|
|
? [
|
|
{
|
|
col: entity,
|
|
op: 'IN',
|
|
val: values,
|
|
},
|
|
]
|
|
: [],
|
|
},
|
|
filterState: {
|
|
value: values.length ? values : null,
|
|
selectedValues: values.length ? [key] : null,
|
|
},
|
|
},
|
|
isCurrentValueSelected: selected.includes(key),
|
|
};
|
|
};
|
|
|
|
const handleClick = source => {
|
|
if (!emitCrossFilters) {
|
|
return;
|
|
}
|
|
const pointerEvent = d3.event;
|
|
pointerEvent.preventDefault();
|
|
getCrossFilterDataMask(source);
|
|
|
|
const dataMask = getCrossFilterDataMask(source)?.dataMask;
|
|
if (dataMask) {
|
|
setDataMask(dataMask);
|
|
}
|
|
};
|
|
|
|
const handleContextMenu = source => {
|
|
const pointerEvent = d3.event;
|
|
pointerEvent.preventDefault();
|
|
const key = source.id || source.country;
|
|
const val =
|
|
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.code;
|
|
let drillToDetailFilters;
|
|
let drillByFilters;
|
|
if (val) {
|
|
drillToDetailFilters = [
|
|
{
|
|
col: entity,
|
|
op: '==',
|
|
val,
|
|
formattedVal: val,
|
|
},
|
|
];
|
|
drillByFilters = [
|
|
{
|
|
col: entity,
|
|
op: '==',
|
|
val,
|
|
},
|
|
];
|
|
}
|
|
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
|
|
drillToDetail: drillToDetailFilters,
|
|
crossFilter: getCrossFilterDataMask(source),
|
|
drillBy: { filters: drillByFilters, groupbyFieldName: 'entity' },
|
|
});
|
|
};
|
|
|
|
const map = new Datamap({
|
|
element,
|
|
width,
|
|
height,
|
|
data: processedData,
|
|
fills: {
|
|
defaultFill: theme.colorBorder,
|
|
},
|
|
geographyConfig: {
|
|
popupOnHover: !inContextMenu,
|
|
highlightOnHover: !inContextMenu,
|
|
borderWidth: 1,
|
|
borderColor: theme.colorSplit,
|
|
highlightBorderColor: theme.colorIcon,
|
|
highlightFillColor: color,
|
|
highlightBorderWidth: 1,
|
|
popupTemplate: (geo, d) =>
|
|
`<div class="hoverinfo"><strong>${d.name}</strong><br>${formatter(
|
|
d.m1,
|
|
)}</div>`,
|
|
},
|
|
bubblesConfig: {
|
|
borderWidth: 1,
|
|
borderOpacity: 1,
|
|
borderColor: color,
|
|
popupOnHover: !inContextMenu,
|
|
radius: null,
|
|
popupTemplate: (geo, d) =>
|
|
`<div class="hoverinfo"><strong>${d.name}</strong><br>${formatter(
|
|
d.m2,
|
|
)}</div>`,
|
|
fillOpacity: 0.5,
|
|
animate: true,
|
|
highlightOnHover: !inContextMenu,
|
|
highlightFillColor: color,
|
|
highlightBorderColor: theme.colorTextSecondary,
|
|
highlightBorderWidth: 2,
|
|
highlightBorderOpacity: 1,
|
|
highlightFillOpacity: 0.85,
|
|
exitDelay: 100,
|
|
key: JSON.stringify,
|
|
},
|
|
done: datamap => {
|
|
datamap.svg
|
|
.selectAll('.datamaps-subunit')
|
|
.on('contextmenu', handleContextMenu)
|
|
.on('click', handleClick);
|
|
},
|
|
});
|
|
|
|
map.updateChoropleth(mapData);
|
|
|
|
if (showBubbles) {
|
|
map.bubbles(processedData);
|
|
div
|
|
.selectAll('circle.datamaps-bubble')
|
|
.style('fill', color)
|
|
.style('stroke', color)
|
|
.on('contextmenu', handleContextMenu)
|
|
.on('click', handleClick);
|
|
}
|
|
|
|
if (filterState.selectedValues?.length > 0) {
|
|
d3.selectAll('path.datamaps-subunit')
|
|
.filter(
|
|
countryFeature =>
|
|
!filterState.selectedValues.includes(countryFeature.id),
|
|
)
|
|
.style('fill-opacity', 0.35);
|
|
|
|
// hack to ensure that the clicked country's color is preserved
|
|
// sometimes the fill color would get default grey value after applying cross filter
|
|
filterState.selectedValues.forEach(value => {
|
|
d3.select(`path.datamaps-subunit.${value}`).style(
|
|
'fill',
|
|
mapData[value]?.fillColor,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
WorldMap.displayName = 'WorldMap';
|
|
WorldMap.propTypes = propTypes;
|
|
|
|
export default WorldMap;
|