worldMapChart.tsx 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. import {useEffect, useState} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import type {MapSeriesOption, TooltipComponentOption} from 'echarts';
  4. import * as echarts from 'echarts/core';
  5. import max from 'lodash/max';
  6. import {Series, SeriesDataUnit} from 'sentry/types/echarts';
  7. import VisualMap from './components/visualMap';
  8. import MapSeries from './series/mapSeries';
  9. import BaseChart from './baseChart';
  10. type ChartProps = Omit<React.ComponentProps<typeof BaseChart>, 'css'>;
  11. type MapChartSeriesDataUnit = Omit<SeriesDataUnit, 'name' | 'itemStyle'> & {
  12. // Docs for map itemStyle differ from Series data unit. See https://echarts.apache.org/en/option.html#series-map.data.itemStyle
  13. itemStyle?: MapSeriesOption['itemStyle'];
  14. name?: string;
  15. };
  16. type MapChartSeries = Omit<Series, 'data'> & {
  17. data: MapChartSeriesDataUnit[];
  18. };
  19. export interface WorldMapChartProps extends Omit<ChartProps, 'series'> {
  20. series: MapChartSeries[];
  21. fromDiscover?: boolean;
  22. fromDiscoverQueryList?: boolean;
  23. seriesOptions?: MapSeriesOption;
  24. }
  25. type JSONResult = Record<string, any>;
  26. type State = {
  27. codeToCountryMap: JSONResult | null;
  28. countryToCodeMap: JSONResult | null;
  29. map: JSONResult | null;
  30. };
  31. const DEFAULT_ZOOM = 1.3;
  32. const DISCOVER_ZOOM = 1.1;
  33. const DISCOVER_QUERY_LIST_ZOOM = 0.9;
  34. const DEFAULT_CENTER_X = 10.97;
  35. const DISCOVER_QUERY_LIST_CENTER_Y = -12;
  36. const DEFAULT_CENTER_Y = 9.71;
  37. export function WorldMapChart({
  38. series,
  39. seriesOptions,
  40. fromDiscover,
  41. fromDiscoverQueryList,
  42. ...props
  43. }: WorldMapChartProps) {
  44. const theme = useTheme();
  45. const [state, setState] = useState<State>(() => ({
  46. countryToCodeMap: null,
  47. map: null,
  48. codeToCountryMap: null,
  49. }));
  50. useEffect(() => {
  51. let unmounted = false;
  52. if (!unmounted) {
  53. loadWorldMap();
  54. }
  55. return () => {
  56. unmounted = true;
  57. };
  58. }, []);
  59. async function loadWorldMap() {
  60. try {
  61. const [countryCodesMap, world] = await Promise.all([
  62. import('sentry/data/countryCodesMap'),
  63. import('sentry/data/world.json'),
  64. ]);
  65. const countryToCodeMap = countryCodesMap.default;
  66. const worldMap = world.default;
  67. // Echarts not available in tests
  68. echarts.registerMap?.('sentryWorld', worldMap as any);
  69. const codeToCountryMap: Record<string, string> = {};
  70. for (const country in worldMap) {
  71. codeToCountryMap[countryToCodeMap[country]] = country;
  72. }
  73. setState({
  74. countryToCodeMap,
  75. map: worldMap,
  76. codeToCountryMap,
  77. });
  78. } catch {
  79. // do nothing
  80. }
  81. }
  82. if (state.countryToCodeMap === null || state.map === null) {
  83. return null;
  84. }
  85. const processedSeries = series.map(({seriesName, data, ...options}) =>
  86. MapSeries({
  87. ...seriesOptions,
  88. ...options,
  89. map: 'sentryWorld',
  90. name: seriesName,
  91. nameMap: state.countryToCodeMap ?? undefined,
  92. aspectScale: 0.85,
  93. zoom: fromDiscover
  94. ? DISCOVER_ZOOM
  95. : fromDiscoverQueryList
  96. ? DISCOVER_QUERY_LIST_ZOOM
  97. : DEFAULT_ZOOM,
  98. center: [
  99. DEFAULT_CENTER_X,
  100. fromDiscoverQueryList ? DISCOVER_QUERY_LIST_CENTER_Y : DEFAULT_CENTER_Y,
  101. ],
  102. itemStyle: {
  103. areaColor: theme.gray200,
  104. borderColor: theme.backgroundSecondary,
  105. },
  106. emphasis: {
  107. itemStyle: {
  108. areaColor: theme.pink300,
  109. },
  110. label: {
  111. show: false,
  112. },
  113. },
  114. data,
  115. silent: fromDiscoverQueryList,
  116. roam: !fromDiscoverQueryList,
  117. })
  118. );
  119. // TODO(billy):
  120. // For absolute values, we want min/max to based on min/max of series
  121. // Otherwise it should be 0-100
  122. const maxValue = max(series.map(({data}) => max(data.map(({value}) => value)))) || 1;
  123. const tooltipFormatter: TooltipComponentOption['formatter'] = (format: any) => {
  124. const {marker, name, value} = Array.isArray(format) ? format[0] : format;
  125. // If value is NaN, don't show anything because we won't have a country code either
  126. if (isNaN(value as number)) {
  127. return '';
  128. }
  129. // `value` should be a number
  130. const formattedValue = typeof value === 'number' ? value.toLocaleString() : '';
  131. const countryOrCode = state.codeToCountryMap?.[name as string] || name;
  132. return [
  133. `<div class="tooltip-series tooltip-series-solo">
  134. <div><span class="tooltip-label">${marker} <strong>${countryOrCode}</strong></span> ${formattedValue}</div>
  135. </div>`,
  136. '<div class="tooltip-arrow"></div>',
  137. ].join('');
  138. };
  139. return (
  140. <BaseChart
  141. options={{
  142. backgroundColor: fromDiscoverQueryList ? undefined : theme.background,
  143. visualMap: [
  144. VisualMap({
  145. show: !fromDiscoverQueryList,
  146. left: fromDiscover ? undefined : 'right',
  147. right: fromDiscover ? 5 : undefined,
  148. min: 0,
  149. max: maxValue,
  150. inRange: {
  151. color: [theme.purple200, theme.purple300],
  152. },
  153. text: ['High', 'Low'],
  154. textStyle: {
  155. color: theme.textColor,
  156. },
  157. // Whether show handles, which can be dragged to adjust "selected range".
  158. // False because the handles are pretty ugly
  159. calculable: false,
  160. }),
  161. ],
  162. }}
  163. {...props}
  164. yAxis={null}
  165. xAxis={null}
  166. series={processedSeries}
  167. tooltip={{
  168. formatter: tooltipFormatter,
  169. }}
  170. height={fromDiscover ? 400 : undefined}
  171. />
  172. );
  173. }