123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200 |
- 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<React.ComponentProps<typeof BaseChart>, 'css'>;
- type MapChartSeriesDataUnit = Omit<SeriesDataUnit, 'name' | 'itemStyle'> & {
- // 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<Series, 'data'> & {
- data: MapChartSeriesDataUnit[];
- };
- export interface WorldMapChartProps extends Omit<ChartProps, 'series'> {
- series: MapChartSeries[];
- fromDiscover?: boolean;
- fromDiscoverQueryList?: boolean;
- seriesOptions?: MapSeriesOption;
- }
- type JSONResult = Record<string, any>;
- 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<State>(() => ({
- 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<string, string> = {};
- 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 [
- `<div class="tooltip-series tooltip-series-solo">
- <div><span class="tooltip-label">${marker} <strong>${countryOrCode}</strong></span> ${formattedValue}</div>
- </div>`,
- '<div class="tooltip-arrow"></div>',
- ].join('');
- };
- return (
- <BaseChart
- options={{
- backgroundColor: fromDiscoverQueryList ? undefined : theme.background,
- visualMap: [
- VisualMap({
- show: !fromDiscoverQueryList,
- left: fromDiscover ? undefined : 'right',
- right: fromDiscover ? 5 : undefined,
- min: 0,
- max: maxValue,
- inRange: {
- color: [theme.purple200, theme.purple300],
- },
- text: ['High', 'Low'],
- textStyle: {
- color: theme.textColor,
- },
- // Whether show handles, which can be dragged to adjust "selected range".
- // False because the handles are pretty ugly
- calculable: false,
- }),
- ],
- }}
- {...props}
- yAxis={null}
- xAxis={null}
- series={processedSeries}
- tooltip={{
- formatter: tooltipFormatter,
- }}
- height={fromDiscover ? 400 : undefined}
- />
- );
- }
|