import {useEffect, useState} from 'react'; import {useTheme} from '@emotion/react'; import type {MapSeriesOption, TooltipComponentOption} from 'echarts'; import * as echarts from 'echarts/core'; import max from 'lodash/max'; import {Series, SeriesDataUnit} from 'sentry/types/echarts'; import VisualMap from './components/visualMap'; import MapSeries from './series/mapSeries'; import BaseChart from './baseChart'; type ChartProps = Omit, 'css'>; type MapChartSeriesDataUnit = Omit & { // Docs for map itemStyle differ from Series data unit. See https://echarts.apache.org/en/option.html#series-map.data.itemStyle itemStyle?: MapSeriesOption['itemStyle']; name?: string; }; type MapChartSeries = Omit & { data: MapChartSeriesDataUnit[]; }; export interface WorldMapChartProps extends Omit { series: MapChartSeries[]; fromDiscover?: boolean; fromDiscoverQueryList?: boolean; seriesOptions?: MapSeriesOption; } type JSONResult = Record; type State = { codeToCountryMap: JSONResult | null; countryToCodeMap: JSONResult | null; map: JSONResult | null; }; const DEFAULT_ZOOM = 1.3; const DISCOVER_ZOOM = 1.1; const DISCOVER_QUERY_LIST_ZOOM = 0.9; const DEFAULT_CENTER_X = 10.97; const DISCOVER_QUERY_LIST_CENTER_Y = -12; const DEFAULT_CENTER_Y = 9.71; export function WorldMapChart({ series, seriesOptions, fromDiscover, fromDiscoverQueryList, ...props }: WorldMapChartProps) { const theme = useTheme(); const [state, setState] = useState(() => ({ countryToCodeMap: null, map: null, codeToCountryMap: null, })); useEffect(() => { let unmounted = false; if (!unmounted) { loadWorldMap(); } return () => { unmounted = true; }; }, []); async function loadWorldMap() { try { const [countryCodesMap, world] = await Promise.all([ import('sentry/data/countryCodesMap'), import('sentry/data/world.json'), ]); const countryToCodeMap = countryCodesMap.default; const worldMap = world.default; // Echarts not available in tests echarts.registerMap?.('sentryWorld', worldMap as any); const codeToCountryMap: Record = {}; for (const country in worldMap) { codeToCountryMap[countryToCodeMap[country]] = country; } setState({ countryToCodeMap, map: worldMap, codeToCountryMap, }); } catch { // do nothing } } if (state.countryToCodeMap === null || state.map === null) { return null; } const processedSeries = series.map(({seriesName, data, ...options}) => MapSeries({ ...seriesOptions, ...options, map: 'sentryWorld', name: seriesName, nameMap: state.countryToCodeMap ?? undefined, aspectScale: 0.85, zoom: fromDiscover ? DISCOVER_ZOOM : fromDiscoverQueryList ? DISCOVER_QUERY_LIST_ZOOM : DEFAULT_ZOOM, center: [ DEFAULT_CENTER_X, fromDiscoverQueryList ? DISCOVER_QUERY_LIST_CENTER_Y : DEFAULT_CENTER_Y, ], itemStyle: { areaColor: theme.gray200, borderColor: theme.backgroundSecondary, }, emphasis: { itemStyle: { areaColor: theme.pink300, }, label: { show: false, }, }, data, silent: fromDiscoverQueryList, roam: !fromDiscoverQueryList, }) ); // TODO(billy): // For absolute values, we want min/max to based on min/max of series // Otherwise it should be 0-100 const maxValue = max(series.map(({data}) => max(data.map(({value}) => value)))) || 1; const tooltipFormatter: TooltipComponentOption['formatter'] = (format: any) => { const {marker, name, value} = Array.isArray(format) ? format[0] : format; // If value is NaN, don't show anything because we won't have a country code either if (isNaN(value as number)) { return ''; } // `value` should be a number const formattedValue = typeof value === 'number' ? value.toLocaleString() : ''; const countryOrCode = state.codeToCountryMap?.[name as string] || name; return [ `
${marker} ${countryOrCode} ${formattedValue}
`, '
', ].join(''); }; return ( ); }