chart.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. import {browserHistory, withRouter, WithRouterProps} from 'react-router';
  2. import {useTheme} from '@emotion/react';
  3. import type {LegendComponentOption} from 'echarts';
  4. import ChartZoom from 'sentry/components/charts/chartZoom';
  5. import LineChart, {LineChartSeries} from 'sentry/components/charts/lineChart';
  6. import TransitionChart from 'sentry/components/charts/transitionChart';
  7. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  8. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  9. import {t} from 'sentry/locale';
  10. import {EventsStatsData, OrganizationSummary, Project} from 'sentry/types';
  11. import {Series} from 'sentry/types/echarts';
  12. import {getUtcToLocalDateObject} from 'sentry/utils/dates';
  13. import {axisLabelFormatter, tooltipFormatter} from 'sentry/utils/discover/charts';
  14. import getDynamicText from 'sentry/utils/getDynamicText';
  15. import {decodeList} from 'sentry/utils/queryString';
  16. import {Theme} from 'sentry/utils/theme';
  17. import {ViewProps} from '../types';
  18. import {
  19. NormalizedTrendsTransaction,
  20. TrendChangeType,
  21. TrendFunctionField,
  22. TrendsStats,
  23. } from './types';
  24. import {
  25. generateTrendFunctionAsString,
  26. getCurrentTrendFunction,
  27. getCurrentTrendParameter,
  28. getUnselectedSeries,
  29. transformEventStatsSmoothed,
  30. trendToColor,
  31. } from './utils';
  32. type Props = WithRouterProps &
  33. ViewProps & {
  34. isLoading: boolean;
  35. location: Location;
  36. organization: OrganizationSummary;
  37. projects: Project[];
  38. statsData: TrendsStats;
  39. trendChangeType: TrendChangeType;
  40. disableLegend?: boolean;
  41. disableXAxis?: boolean;
  42. grid?: React.ComponentProps<typeof LineChart>['grid'];
  43. height?: number;
  44. transaction?: NormalizedTrendsTransaction;
  45. trendFunctionField?: TrendFunctionField;
  46. };
  47. function transformEventStats(data: EventsStatsData, seriesName?: string): Series[] {
  48. return [
  49. {
  50. seriesName: seriesName || 'Current',
  51. data: data.map(([timestamp, countsForTimestamp]) => ({
  52. name: timestamp * 1000,
  53. value: countsForTimestamp.reduce((acc, {count}) => acc + count, 0),
  54. })),
  55. },
  56. ];
  57. }
  58. function getLegend(trendFunction: string): LegendComponentOption {
  59. return {
  60. right: 10,
  61. top: 0,
  62. itemGap: 12,
  63. align: 'left',
  64. data: [
  65. {
  66. name: 'Baseline',
  67. icon: 'path://M180 1000 l0 -40 200 0 200 0 0 40 0 40 -200 0 -200 0 0 -40z, M810 1000 l0 -40 200 0 200 0 0 40 0 40 -200 0 -200 0 0 -40zm, M1440 1000 l0 -40 200 0 200 0 0 40 0 40 -200 0 -200 0 0 -40z',
  68. },
  69. {
  70. name: 'Releases',
  71. },
  72. {
  73. name: trendFunction,
  74. },
  75. ],
  76. };
  77. }
  78. function getIntervalLine(
  79. theme: Theme,
  80. series: Series[],
  81. intervalRatio: number,
  82. transaction?: NormalizedTrendsTransaction
  83. ): LineChartSeries[] {
  84. if (!transaction || !series.length || !series[0].data || !series[0].data.length) {
  85. return [];
  86. }
  87. const seriesStart = parseInt(series[0].data[0].name as string, 0);
  88. const seriesEnd = parseInt(series[0].data.slice(-1)[0].name as string, 0);
  89. if (seriesEnd < seriesStart) {
  90. return [];
  91. }
  92. const periodLine: LineChartSeries = {
  93. data: [],
  94. color: theme.textColor,
  95. markLine: {
  96. data: [],
  97. label: {},
  98. lineStyle: {
  99. color: theme.textColor,
  100. type: 'dashed',
  101. width: 1,
  102. },
  103. symbol: ['none', 'none'],
  104. tooltip: {
  105. show: false,
  106. },
  107. },
  108. seriesName: 'Baseline',
  109. };
  110. const periodLineLabel = {
  111. fontSize: 11,
  112. show: true,
  113. color: theme.textColor,
  114. silent: true,
  115. };
  116. const previousPeriod = {
  117. ...periodLine,
  118. markLine: {...periodLine.markLine},
  119. seriesName: 'Baseline',
  120. };
  121. const currentPeriod = {
  122. ...periodLine,
  123. markLine: {...periodLine.markLine},
  124. seriesName: 'Baseline',
  125. };
  126. const periodDividingLine = {
  127. ...periodLine,
  128. markLine: {...periodLine.markLine},
  129. seriesName: 'Period split',
  130. };
  131. const seriesDiff = seriesEnd - seriesStart;
  132. const seriesLine = seriesDiff * intervalRatio + seriesStart;
  133. previousPeriod.markLine.data = [
  134. [
  135. {value: 'Past', coord: [seriesStart, transaction.aggregate_range_1]},
  136. {coord: [seriesLine, transaction.aggregate_range_1]},
  137. ],
  138. ];
  139. previousPeriod.markLine.tooltip = {
  140. formatter: () => {
  141. return [
  142. '<div class="tooltip-series tooltip-series-solo">',
  143. '<div>',
  144. `<span class="tooltip-label"><strong>${t('Past Baseline')}</strong></span>`,
  145. // p50() coerces the axis to be time based
  146. tooltipFormatter(transaction.aggregate_range_1, 'p50()'),
  147. '</div>',
  148. '</div>',
  149. '<div class="tooltip-arrow"></div>',
  150. ].join('');
  151. },
  152. };
  153. currentPeriod.markLine.data = [
  154. [
  155. {value: 'Present', coord: [seriesLine, transaction.aggregate_range_2]},
  156. {coord: [seriesEnd, transaction.aggregate_range_2]},
  157. ],
  158. ];
  159. currentPeriod.markLine.tooltip = {
  160. formatter: () => {
  161. return [
  162. '<div class="tooltip-series tooltip-series-solo">',
  163. '<div>',
  164. `<span class="tooltip-label"><strong>${t('Present Baseline')}</strong></span>`,
  165. // p50() coerces the axis to be time based
  166. tooltipFormatter(transaction.aggregate_range_2, 'p50()'),
  167. '</div>',
  168. '</div>',
  169. '<div class="tooltip-arrow"></div>',
  170. ].join('');
  171. },
  172. };
  173. periodDividingLine.markLine = {
  174. data: [
  175. {
  176. xAxis: seriesLine,
  177. },
  178. ],
  179. label: {show: false},
  180. lineStyle: {
  181. color: theme.textColor,
  182. type: 'solid',
  183. width: 2,
  184. },
  185. symbol: ['none', 'none'],
  186. tooltip: {
  187. show: false,
  188. },
  189. silent: true,
  190. };
  191. previousPeriod.markLine.label = {
  192. ...periodLineLabel,
  193. formatter: 'Past',
  194. position: 'insideStartBottom',
  195. };
  196. currentPeriod.markLine.label = {
  197. ...periodLineLabel,
  198. formatter: 'Present',
  199. position: 'insideEndBottom',
  200. };
  201. const additionalLineSeries = [previousPeriod, currentPeriod, periodDividingLine];
  202. return additionalLineSeries;
  203. }
  204. export function Chart({
  205. trendChangeType,
  206. router,
  207. statsPeriod,
  208. transaction,
  209. statsData,
  210. isLoading,
  211. location,
  212. start: propsStart,
  213. end: propsEnd,
  214. trendFunctionField,
  215. disableXAxis,
  216. disableLegend,
  217. grid,
  218. height,
  219. projects,
  220. project,
  221. }: Props) {
  222. const theme = useTheme();
  223. const handleLegendSelectChanged = legendChange => {
  224. const {selected} = legendChange;
  225. const unselected = Object.keys(selected).filter(key => !selected[key]);
  226. const query = {
  227. ...location.query,
  228. };
  229. const queryKey = getUnselectedSeries(trendChangeType);
  230. query[queryKey] = unselected;
  231. const to = {
  232. ...location,
  233. query,
  234. };
  235. browserHistory.push(to);
  236. };
  237. const lineColor = trendToColor[trendChangeType || ''];
  238. const events =
  239. statsData && transaction?.project && transaction?.transaction
  240. ? statsData[[transaction.project, transaction.transaction].join(',')]
  241. : undefined;
  242. const data = events?.data ?? [];
  243. const trendFunction = getCurrentTrendFunction(location, trendFunctionField);
  244. const trendParameter = getCurrentTrendParameter(location, projects, project);
  245. const chartLabel = generateTrendFunctionAsString(
  246. trendFunction.field,
  247. trendParameter.column
  248. );
  249. const results = transformEventStats(data, chartLabel);
  250. const {smoothedResults, minValue, maxValue} = transformEventStatsSmoothed(
  251. results,
  252. chartLabel
  253. );
  254. const start = propsStart ? getUtcToLocalDateObject(propsStart) : null;
  255. const end = propsEnd ? getUtcToLocalDateObject(propsEnd) : null;
  256. const {utc} = normalizeDateTimeParams(location.query);
  257. const seriesSelection = decodeList(
  258. location.query[getUnselectedSeries(trendChangeType)]
  259. ).reduce((selection, metric) => {
  260. selection[metric] = false;
  261. return selection;
  262. }, {});
  263. const legend: LegendComponentOption = disableLegend
  264. ? {show: false}
  265. : {
  266. ...getLegend(chartLabel),
  267. selected: seriesSelection,
  268. };
  269. const loading = isLoading;
  270. const reloading = isLoading;
  271. const yMax = Math.max(
  272. maxValue,
  273. transaction?.aggregate_range_2 || 0,
  274. transaction?.aggregate_range_1 || 0
  275. );
  276. const yMin = Math.min(
  277. minValue,
  278. transaction?.aggregate_range_1 || Number.MAX_SAFE_INTEGER,
  279. transaction?.aggregate_range_2 || Number.MAX_SAFE_INTEGER
  280. );
  281. const yDiff = yMax - yMin;
  282. const yMargin = yDiff * 0.1;
  283. const chartOptions = {
  284. tooltip: {
  285. valueFormatter: (value, seriesName) => {
  286. return tooltipFormatter(value, seriesName);
  287. },
  288. },
  289. yAxis: {
  290. min: Math.max(0, yMin - yMargin),
  291. max: yMax + yMargin,
  292. axisLabel: {
  293. color: theme.chartLabel,
  294. // p50() coerces the axis to be time based
  295. formatter: (value: number) => axisLabelFormatter(value, 'p50()'),
  296. },
  297. },
  298. };
  299. return (
  300. <ChartZoom
  301. router={router}
  302. period={statsPeriod}
  303. start={start}
  304. end={end}
  305. utc={utc === 'true'}
  306. >
  307. {zoomRenderProps => {
  308. const smoothedSeries = smoothedResults
  309. ? smoothedResults.map(values => {
  310. return {
  311. ...values,
  312. color: lineColor.default,
  313. lineStyle: {
  314. opacity: 1,
  315. },
  316. };
  317. })
  318. : [];
  319. const intervalSeries = getIntervalLine(
  320. theme,
  321. smoothedResults || [],
  322. 0.5,
  323. transaction
  324. );
  325. return (
  326. <TransitionChart loading={loading} reloading={reloading}>
  327. <TransparentLoadingMask visible={reloading} />
  328. {getDynamicText({
  329. value: (
  330. <LineChart
  331. height={height}
  332. {...zoomRenderProps}
  333. {...chartOptions}
  334. onLegendSelectChanged={handleLegendSelectChanged}
  335. series={[...smoothedSeries, ...intervalSeries]}
  336. seriesOptions={{
  337. showSymbol: false,
  338. }}
  339. legend={legend}
  340. toolBox={{
  341. show: false,
  342. }}
  343. grid={
  344. grid ?? {
  345. left: '10px',
  346. right: '10px',
  347. top: '40px',
  348. bottom: '0px',
  349. }
  350. }
  351. xAxis={disableXAxis ? {show: false} : undefined}
  352. />
  353. ),
  354. fixed: 'Duration Chart',
  355. })}
  356. </TransitionChart>
  357. );
  358. }}
  359. </ChartZoom>
  360. );
  361. }
  362. export default withRouter(Chart);