chart.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  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. location: Location;
  35. organization: OrganizationSummary;
  36. trendChangeType: TrendChangeType;
  37. trendFunctionField?: TrendFunctionField;
  38. isLoading: boolean;
  39. statsData: TrendsStats;
  40. projects: Project[];
  41. transaction?: NormalizedTrendsTransaction;
  42. height?: number;
  43. grid?: React.ComponentProps<typeof LineChart>['grid'];
  44. disableXAxis?: boolean;
  45. disableLegend?: boolean;
  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. }: Props) {
  220. const theme = useTheme();
  221. const handleLegendSelectChanged = legendChange => {
  222. const {selected} = legendChange;
  223. const unselected = Object.keys(selected).filter(key => !selected[key]);
  224. const query = {
  225. ...location.query,
  226. };
  227. const queryKey = getUnselectedSeries(trendChangeType);
  228. query[queryKey] = unselected;
  229. const to = {
  230. ...location,
  231. query,
  232. };
  233. browserHistory.push(to);
  234. };
  235. const lineColor = trendToColor[trendChangeType || ''];
  236. const events =
  237. statsData && transaction?.project && transaction?.transaction
  238. ? statsData[[transaction.project, transaction.transaction].join(',')]
  239. : undefined;
  240. const data = events?.data ?? [];
  241. const trendFunction = getCurrentTrendFunction(location, trendFunctionField);
  242. const trendParameter = getCurrentTrendParameter(location);
  243. const chartLabel = generateTrendFunctionAsString(
  244. trendFunction.field,
  245. trendParameter.column
  246. );
  247. const results = transformEventStats(data, chartLabel);
  248. const {smoothedResults, minValue, maxValue} = transformEventStatsSmoothed(
  249. results,
  250. chartLabel
  251. );
  252. const start = propsStart ? getUtcToLocalDateObject(propsStart) : null;
  253. const end = propsEnd ? getUtcToLocalDateObject(propsEnd) : null;
  254. const {utc} = normalizeDateTimeParams(location.query);
  255. const seriesSelection = decodeList(
  256. location.query[getUnselectedSeries(trendChangeType)]
  257. ).reduce((selection, metric) => {
  258. selection[metric] = false;
  259. return selection;
  260. }, {});
  261. const legend: LegendComponentOption = disableLegend
  262. ? {show: false}
  263. : {
  264. ...getLegend(chartLabel),
  265. selected: seriesSelection,
  266. };
  267. const loading = isLoading;
  268. const reloading = isLoading;
  269. const yMax = Math.max(
  270. maxValue,
  271. transaction?.aggregate_range_2 || 0,
  272. transaction?.aggregate_range_1 || 0
  273. );
  274. const yMin = Math.min(
  275. minValue,
  276. transaction?.aggregate_range_1 || Number.MAX_SAFE_INTEGER,
  277. transaction?.aggregate_range_2 || Number.MAX_SAFE_INTEGER
  278. );
  279. const yDiff = yMax - yMin;
  280. const yMargin = yDiff * 0.1;
  281. const chartOptions = {
  282. tooltip: {
  283. valueFormatter: (value, seriesName) => {
  284. return tooltipFormatter(value, seriesName);
  285. },
  286. },
  287. yAxis: {
  288. min: Math.max(0, yMin - yMargin),
  289. max: yMax + yMargin,
  290. axisLabel: {
  291. color: theme.chartLabel,
  292. // p50() coerces the axis to be time based
  293. formatter: (value: number) => axisLabelFormatter(value, 'p50()'),
  294. },
  295. },
  296. };
  297. return (
  298. <ChartZoom
  299. router={router}
  300. period={statsPeriod}
  301. start={start}
  302. end={end}
  303. utc={utc === 'true'}
  304. >
  305. {zoomRenderProps => {
  306. const smoothedSeries = smoothedResults
  307. ? smoothedResults.map(values => {
  308. return {
  309. ...values,
  310. color: lineColor.default,
  311. lineStyle: {
  312. opacity: 1,
  313. },
  314. };
  315. })
  316. : [];
  317. const intervalSeries = getIntervalLine(
  318. theme,
  319. smoothedResults || [],
  320. 0.5,
  321. transaction
  322. );
  323. return (
  324. <TransitionChart loading={loading} reloading={reloading}>
  325. <TransparentLoadingMask visible={reloading} />
  326. {getDynamicText({
  327. value: (
  328. <LineChart
  329. height={height}
  330. {...zoomRenderProps}
  331. {...chartOptions}
  332. onLegendSelectChanged={handleLegendSelectChanged}
  333. series={[...smoothedSeries, ...intervalSeries]}
  334. seriesOptions={{
  335. showSymbol: false,
  336. }}
  337. legend={legend}
  338. toolBox={{
  339. show: false,
  340. }}
  341. grid={
  342. grid ?? {
  343. left: '10px',
  344. right: '10px',
  345. top: '40px',
  346. bottom: '0px',
  347. }
  348. }
  349. xAxis={disableXAxis ? {show: false} : undefined}
  350. />
  351. ),
  352. fixed: 'Duration Chart',
  353. })}
  354. </TransitionChart>
  355. );
  356. }}
  357. </ChartZoom>
  358. );
  359. }
  360. export default withRouter(Chart);