chart.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. import {Component} from 'react';
  2. import type {InjectedRouter} from 'react-router';
  3. import type {Theme} from '@emotion/react';
  4. import {withTheme} from '@emotion/react';
  5. import styled from '@emotion/styled';
  6. import type {DataZoomComponentOption, LegendComponentOption} from 'echarts';
  7. import type {Location} from 'history';
  8. import isEqual from 'lodash/isEqual';
  9. import omit from 'lodash/omit';
  10. import {AreaChart} from 'sentry/components/charts/areaChart';
  11. import {BarChart} from 'sentry/components/charts/barChart';
  12. import ChartZoom from 'sentry/components/charts/chartZoom';
  13. import ErrorPanel from 'sentry/components/charts/errorPanel';
  14. import {LineChart} from 'sentry/components/charts/lineChart';
  15. import SimpleTableChart from 'sentry/components/charts/simpleTableChart';
  16. import TransitionChart from 'sentry/components/charts/transitionChart';
  17. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  18. import {getSeriesSelection, isChartHovered} from 'sentry/components/charts/utils';
  19. import LoadingIndicator from 'sentry/components/loadingIndicator';
  20. import type {PlaceholderProps} from 'sentry/components/placeholder';
  21. import Placeholder from 'sentry/components/placeholder';
  22. import {Tooltip} from 'sentry/components/tooltip';
  23. import {IconWarning} from 'sentry/icons';
  24. import {space} from 'sentry/styles/space';
  25. import type {PageFilters} from 'sentry/types/core';
  26. import type {
  27. EChartDataZoomHandler,
  28. EChartEventHandler,
  29. ReactEchartsRef,
  30. Series,
  31. } from 'sentry/types/echarts';
  32. import type {Organization} from 'sentry/types/organization';
  33. import {
  34. axisLabelFormatter,
  35. axisLabelFormatterUsingAggregateOutputType,
  36. getDurationUnit,
  37. tooltipFormatter,
  38. } from 'sentry/utils/discover/charts';
  39. import {getFieldFormatter} from 'sentry/utils/discover/fieldRenderers';
  40. import type {AggregationOutputType} from 'sentry/utils/discover/fields';
  41. import {
  42. aggregateOutputType,
  43. getAggregateArg,
  44. getEquation,
  45. getMeasurementSlug,
  46. isEquation,
  47. maybeEquationAlias,
  48. stripDerivedMetricsPrefix,
  49. stripEquationPrefix,
  50. } from 'sentry/utils/discover/fields';
  51. import getDynamicText from 'sentry/utils/getDynamicText';
  52. import {eventViewFromWidget} from 'sentry/views/dashboards/utils';
  53. import {getFormatter} from '../../../components/charts/components/tooltip';
  54. import {getDatasetConfig} from '../datasetConfig/base';
  55. import type {Widget} from '../types';
  56. import {DisplayType} from '../types';
  57. import type {GenericWidgetQueriesChildrenProps} from './genericWidgetQueries';
  58. const OTHER = 'Other';
  59. export const SLIDER_HEIGHT = 60;
  60. export type AugmentedEChartDataZoomHandler = (
  61. params: Parameters<EChartDataZoomHandler>[0] & {
  62. seriesEnd: string | number;
  63. seriesStart: string | number;
  64. },
  65. instance: Parameters<EChartDataZoomHandler>[1]
  66. ) => void;
  67. type TableResultProps = Pick<
  68. GenericWidgetQueriesChildrenProps,
  69. 'errorMessage' | 'loading' | 'tableResults'
  70. >;
  71. type WidgetCardChartProps = Pick<
  72. GenericWidgetQueriesChildrenProps,
  73. 'timeseriesResults' | 'tableResults' | 'errorMessage' | 'loading'
  74. > & {
  75. location: Location;
  76. organization: Organization;
  77. router: InjectedRouter;
  78. selection: PageFilters;
  79. theme: Theme;
  80. widget: Widget;
  81. chartGroup?: string;
  82. chartZoomOptions?: DataZoomComponentOption;
  83. expandNumbers?: boolean;
  84. isMobile?: boolean;
  85. legendOptions?: LegendComponentOption;
  86. noPadding?: boolean;
  87. onLegendSelectChanged?: EChartEventHandler<{
  88. name: string;
  89. selected: Record<string, boolean>;
  90. type: 'legendselectchanged';
  91. }>;
  92. onZoom?: AugmentedEChartDataZoomHandler;
  93. shouldResize?: boolean;
  94. showSlider?: boolean;
  95. timeseriesResultsTypes?: Record<string, AggregationOutputType>;
  96. windowWidth?: number;
  97. };
  98. type State = {
  99. // For tracking height of the container wrapping BigNumber widgets
  100. // so we can dynamically scale font-size
  101. containerHeight: number;
  102. };
  103. class WidgetCardChart extends Component<WidgetCardChartProps, State> {
  104. state = {containerHeight: 0};
  105. shouldComponentUpdate(nextProps: WidgetCardChartProps, nextState: State): boolean {
  106. if (
  107. this.props.widget.displayType === DisplayType.BIG_NUMBER &&
  108. nextProps.widget.displayType === DisplayType.BIG_NUMBER &&
  109. (this.props.windowWidth !== nextProps.windowWidth ||
  110. !isEqual(this.props.widget?.layout, nextProps.widget?.layout))
  111. ) {
  112. return true;
  113. }
  114. // Widget title changes should not update the WidgetCardChart component tree
  115. const currentProps = {
  116. ...omit(this.props, ['windowWidth']),
  117. widget: {
  118. ...this.props.widget,
  119. title: '',
  120. },
  121. };
  122. nextProps = {
  123. ...omit(nextProps, ['windowWidth']),
  124. widget: {
  125. ...nextProps.widget,
  126. title: '',
  127. },
  128. };
  129. return !isEqual(currentProps, nextProps) || !isEqual(this.state, nextState);
  130. }
  131. tableResultComponent({
  132. loading,
  133. errorMessage,
  134. tableResults,
  135. }: TableResultProps): React.ReactNode {
  136. const {location, widget, selection} = this.props;
  137. if (errorMessage) {
  138. return (
  139. <StyledErrorPanel>
  140. <IconWarning color="gray500" size="lg" />
  141. </StyledErrorPanel>
  142. );
  143. }
  144. if (typeof tableResults === 'undefined') {
  145. // Align height to other charts.
  146. return <LoadingPlaceholder />;
  147. }
  148. const datasetConfig = getDatasetConfig(widget.widgetType);
  149. return tableResults.map((result, i) => {
  150. const fields = widget.queries[i]?.fields?.map(stripDerivedMetricsPrefix) ?? [];
  151. const fieldAliases = widget.queries[i]?.fieldAliases ?? [];
  152. const eventView = eventViewFromWidget(widget.title, widget.queries[0], selection);
  153. return (
  154. <StyledSimpleTableChart
  155. key={`table:${result.title}`}
  156. eventView={eventView}
  157. fieldAliases={fieldAliases}
  158. location={location}
  159. fields={fields}
  160. title={tableResults.length > 1 ? result.title : ''}
  161. loading={loading}
  162. loader={<LoadingPlaceholder />}
  163. metadata={result.meta}
  164. data={result.data}
  165. stickyHeaders
  166. fieldHeaderMap={datasetConfig.getFieldHeaderMap?.(widget.queries[i])}
  167. getCustomFieldRenderer={datasetConfig.getCustomFieldRenderer}
  168. />
  169. );
  170. });
  171. }
  172. bigNumberComponent({
  173. loading,
  174. errorMessage,
  175. tableResults,
  176. }: TableResultProps): React.ReactNode {
  177. if (errorMessage) {
  178. return (
  179. <StyledErrorPanel>
  180. <IconWarning color="gray500" size="lg" />
  181. </StyledErrorPanel>
  182. );
  183. }
  184. if (typeof tableResults === 'undefined' || loading) {
  185. return <BigNumber>{'\u2014'}</BigNumber>;
  186. }
  187. const {containerHeight} = this.state;
  188. const {location, organization, widget, isMobile, expandNumbers} = this.props;
  189. return tableResults.map(result => {
  190. const tableMeta = {...result.meta};
  191. const fields = Object.keys(tableMeta);
  192. const field = fields[0];
  193. // Change tableMeta for the field from integer to string since we will be rendering with toLocaleString
  194. const shouldExpandInteger = !!expandNumbers && tableMeta[field] === 'integer';
  195. if (shouldExpandInteger) {
  196. tableMeta[field] = 'string';
  197. }
  198. if (!field || !result.data?.length) {
  199. return <BigNumber key={`big_number:${result.title}`}>{'\u2014'}</BigNumber>;
  200. }
  201. const dataRow = result.data[0];
  202. const fieldRenderer = getFieldFormatter(field, tableMeta, false);
  203. const unit = tableMeta.units?.[field];
  204. const rendered = fieldRenderer(
  205. shouldExpandInteger ? {[field]: dataRow[field].toLocaleString()} : dataRow,
  206. {location, organization, unit}
  207. );
  208. const isModalWidget = !(widget.id || widget.tempId);
  209. if (isModalWidget || isMobile) {
  210. return <BigNumber key={`big_number:${result.title}`}>{rendered}</BigNumber>;
  211. }
  212. // The font size is the container height, minus the top and bottom padding
  213. const fontSize = !expandNumbers
  214. ? containerHeight - parseInt(space(1), 10) - parseInt(space(3), 10)
  215. : `max(min(8vw, 90px), ${space(4)})`;
  216. return (
  217. <BigNumber
  218. key={`big_number:${result.title}`}
  219. style={{
  220. fontSize,
  221. ...(expandNumbers ? {padding: `${space(1)} ${space(3)} 0 ${space(3)}`} : {}),
  222. }}
  223. >
  224. <Tooltip title={rendered} showOnlyOnOverflow>
  225. {rendered}
  226. </Tooltip>
  227. </BigNumber>
  228. );
  229. });
  230. }
  231. chartRef: ReactEchartsRef | null = null;
  232. handleRef = (chartRef: ReactEchartsRef): void => {
  233. if (chartRef && !this.chartRef) {
  234. this.chartRef = chartRef;
  235. // add chart to the group so that it has synced cursors
  236. const instance = chartRef.getEchartsInstance?.();
  237. if (instance && !instance.group && this.props.chartGroup) {
  238. instance.group = this.props.chartGroup;
  239. }
  240. }
  241. if (!chartRef) {
  242. this.chartRef = null;
  243. }
  244. };
  245. chartComponent(chartProps): React.ReactNode {
  246. const {widget} = this.props;
  247. const stacked = widget.queries[0]?.columns.length > 0;
  248. switch (widget.displayType) {
  249. case 'bar':
  250. return <BarChart {...chartProps} stacked={stacked} />;
  251. case 'area':
  252. case 'top_n':
  253. return <AreaChart stacked {...chartProps} />;
  254. case 'line':
  255. default:
  256. return <LineChart {...chartProps} />;
  257. }
  258. }
  259. render() {
  260. const {
  261. theme,
  262. tableResults,
  263. timeseriesResults,
  264. errorMessage,
  265. loading,
  266. widget,
  267. onZoom,
  268. legendOptions,
  269. expandNumbers,
  270. showSlider,
  271. noPadding,
  272. chartZoomOptions,
  273. timeseriesResultsTypes,
  274. shouldResize,
  275. } = this.props;
  276. if (widget.displayType === 'table') {
  277. return getDynamicText({
  278. value: (
  279. <TransitionChart loading={loading} reloading={loading}>
  280. <LoadingScreen loading={loading} />
  281. {this.tableResultComponent({tableResults, loading, errorMessage})}
  282. </TransitionChart>
  283. ),
  284. fixed: <Placeholder height="200px" testId="skeleton-ui" />,
  285. });
  286. }
  287. if (widget.displayType === 'big_number') {
  288. return (
  289. <TransitionChart loading={loading} reloading={loading}>
  290. <LoadingScreen loading={loading} />
  291. <BigNumberResizeWrapper
  292. ref={el => {
  293. if (el !== null && !expandNumbers) {
  294. const {height} = el.getBoundingClientRect();
  295. if (height !== this.state.containerHeight) {
  296. this.setState({containerHeight: height});
  297. }
  298. }
  299. }}
  300. >
  301. {this.bigNumberComponent({tableResults, loading, errorMessage})}
  302. </BigNumberResizeWrapper>
  303. </TransitionChart>
  304. );
  305. }
  306. if (errorMessage) {
  307. return (
  308. <StyledErrorPanel>
  309. <IconWarning color="gray500" size="lg" />
  310. </StyledErrorPanel>
  311. );
  312. }
  313. const {location, router, selection, onLegendSelectChanged} = this.props;
  314. const {start, end, period, utc} = selection.datetime;
  315. const legend = {
  316. left: 0,
  317. top: 0,
  318. selected: getSeriesSelection(location),
  319. formatter: (seriesName: string) => {
  320. const arg = getAggregateArg(seriesName);
  321. if (arg !== null) {
  322. const slug = getMeasurementSlug(arg);
  323. if (slug !== null) {
  324. seriesName = slug.toUpperCase();
  325. }
  326. }
  327. if (maybeEquationAlias(seriesName)) {
  328. seriesName = stripEquationPrefix(seriesName);
  329. }
  330. return seriesName;
  331. },
  332. ...legendOptions,
  333. };
  334. const axisField = widget.queries[0]?.aggregates?.[0] ?? 'count()';
  335. const axisLabel = isEquation(axisField) ? getEquation(axisField) : axisField;
  336. // Check to see if all series output types are the same. If not, then default to number.
  337. const outputType =
  338. timeseriesResultsTypes && new Set(Object.values(timeseriesResultsTypes)).size === 1
  339. ? timeseriesResultsTypes[axisLabel]
  340. : 'number';
  341. const isDurationChart = outputType === 'duration';
  342. const durationUnit = isDurationChart
  343. ? timeseriesResults && getDurationUnit(timeseriesResults, legendOptions)
  344. : undefined;
  345. const bucketSize = getBucketSize(timeseriesResults);
  346. const valueFormatter = (value: number, seriesName?: string) => {
  347. const aggregateName = seriesName?.split(':').pop()?.trim();
  348. if (aggregateName) {
  349. return timeseriesResultsTypes
  350. ? tooltipFormatter(value, timeseriesResultsTypes[aggregateName])
  351. : tooltipFormatter(value, aggregateOutputType(aggregateName));
  352. }
  353. return tooltipFormatter(value, 'number');
  354. };
  355. const chartOptions = {
  356. autoHeightResize: shouldResize ?? true,
  357. grid: {
  358. left: 0,
  359. right: 4,
  360. top: '40px',
  361. bottom: showSlider ? SLIDER_HEIGHT : 0,
  362. },
  363. seriesOptions: {
  364. showSymbol: false,
  365. },
  366. tooltip: {
  367. trigger: 'axis',
  368. formatter: (params, asyncTicket) => {
  369. const {chartGroup} = this.props;
  370. const isInGroup =
  371. chartGroup && chartGroup === this.chartRef?.getEchartsInstance().group;
  372. // tooltip is triggered whenever any chart in the group is hovered,
  373. // so we need to check if the mouse is actually over this chart
  374. if (isInGroup && !isChartHovered(this.chartRef)) {
  375. return '';
  376. }
  377. return getFormatter({
  378. valueFormatter,
  379. isGroupedByDate: true,
  380. bucketSize,
  381. addSecondsToTimeFormat: false,
  382. showTimeInTooltip: true,
  383. })(params, asyncTicket);
  384. },
  385. },
  386. yAxis: {
  387. axisLabel: {
  388. color: theme.chartLabel,
  389. formatter: (value: number) => {
  390. if (timeseriesResultsTypes) {
  391. return axisLabelFormatterUsingAggregateOutputType(
  392. value,
  393. outputType,
  394. true,
  395. durationUnit
  396. );
  397. }
  398. return axisLabelFormatter(value, aggregateOutputType(axisLabel), true);
  399. },
  400. },
  401. minInterval: durationUnit ?? 0,
  402. },
  403. xAxis: {
  404. axisPointer: {
  405. snap: true,
  406. },
  407. },
  408. };
  409. return (
  410. <ChartZoom
  411. router={router}
  412. period={period}
  413. start={start}
  414. end={end}
  415. utc={utc}
  416. showSlider={showSlider}
  417. chartZoomOptions={chartZoomOptions}
  418. >
  419. {zoomRenderProps => {
  420. if (errorMessage) {
  421. return (
  422. <StyledErrorPanel>
  423. <IconWarning color="gray500" size="lg" />
  424. </StyledErrorPanel>
  425. );
  426. }
  427. const otherRegex = new RegExp(`(?:.* : ${OTHER}$)|^${OTHER}$`);
  428. const shouldColorOther = timeseriesResults?.some(({seriesName}) =>
  429. seriesName?.match(otherRegex)
  430. );
  431. const colors = timeseriesResults
  432. ? theme.charts.getColorPalette(
  433. timeseriesResults.length - (shouldColorOther ? 3 : 2)
  434. )
  435. : [];
  436. // TODO(wmak): Need to change this when updating dashboards to support variable topEvents
  437. if (shouldColorOther) {
  438. colors[colors.length] = theme.chartOther;
  439. }
  440. // Create a list of series based on the order of the fields,
  441. const series = timeseriesResults
  442. ? timeseriesResults.map((values, i: number) => {
  443. let seriesName = '';
  444. if (values.seriesName !== undefined) {
  445. seriesName = isEquation(values.seriesName)
  446. ? getEquation(values.seriesName)
  447. : values.seriesName;
  448. }
  449. return {
  450. ...values,
  451. seriesName,
  452. color: colors[i],
  453. };
  454. })
  455. : [];
  456. const seriesStart = series[0]?.data[0]?.name;
  457. const seriesEnd = series[0]?.data[series[0].data.length - 1]?.name;
  458. const forwardedRef = this.props.chartGroup ? this.handleRef : undefined;
  459. return (
  460. <TransitionChart loading={loading} reloading={loading}>
  461. <LoadingScreen loading={loading} />
  462. <ChartWrapper autoHeightResize={shouldResize ?? true} noPadding={noPadding}>
  463. {getDynamicText({
  464. value: this.chartComponent({
  465. ...zoomRenderProps,
  466. ...chartOptions,
  467. // Override default datazoom behaviour for updating Global Selection Header
  468. ...(onZoom
  469. ? {
  470. onDataZoom: (evt, chartProps) =>
  471. // Need to pass seriesStart and seriesEnd to onZoom since slider zooms
  472. // callback with percentage instead of datetime values. Passing seriesStart
  473. // and seriesEnd allows calculating datetime values with percentage.
  474. onZoom({...evt, seriesStart, seriesEnd}, chartProps),
  475. }
  476. : {}),
  477. legend,
  478. series,
  479. onLegendSelectChanged,
  480. forwardedRef,
  481. }),
  482. fixed: <Placeholder height="200px" testId="skeleton-ui" />,
  483. })}
  484. </ChartWrapper>
  485. </TransitionChart>
  486. );
  487. }}
  488. </ChartZoom>
  489. );
  490. }
  491. }
  492. const getBucketSize = (series: Series[] | undefined) => {
  493. if (!series || series.length < 2) {
  494. return 0;
  495. }
  496. return Number(series[0].data[1]?.name) - Number(series[0].data[0]?.name);
  497. };
  498. export default withTheme(WidgetCardChart);
  499. const StyledTransparentLoadingMask = styled(props => (
  500. <TransparentLoadingMask {...props} maskBackgroundColor="transparent" />
  501. ))`
  502. display: flex;
  503. justify-content: center;
  504. align-items: center;
  505. `;
  506. function LoadingScreen({loading}: {loading: boolean}) {
  507. if (!loading) {
  508. return null;
  509. }
  510. return (
  511. <StyledTransparentLoadingMask visible={loading}>
  512. <LoadingIndicator mini />
  513. </StyledTransparentLoadingMask>
  514. );
  515. }
  516. const LoadingPlaceholder = styled(({className}: PlaceholderProps) => (
  517. <Placeholder height="200px" className={className} />
  518. ))`
  519. background-color: ${p => p.theme.surface300};
  520. `;
  521. const BigNumberResizeWrapper = styled('div')`
  522. height: 100%;
  523. width: 100%;
  524. overflow: hidden;
  525. `;
  526. const BigNumber = styled('div')`
  527. line-height: 1;
  528. display: inline-flex;
  529. flex: 1;
  530. width: 100%;
  531. min-height: 0;
  532. font-size: 32px;
  533. color: ${p => p.theme.headingColor};
  534. padding: ${space(1)} ${space(3)} ${space(3)} ${space(3)};
  535. * {
  536. text-align: left !important;
  537. }
  538. `;
  539. const ChartWrapper = styled('div')<{autoHeightResize: boolean; noPadding?: boolean}>`
  540. ${p => p.autoHeightResize && 'height: 100%;'}
  541. padding: ${p => (p.noPadding ? `0` : `0 ${space(3)} ${space(3)}`)};
  542. `;
  543. const StyledSimpleTableChart = styled(SimpleTableChart)`
  544. margin-top: ${space(1.5)};
  545. border-bottom-left-radius: ${p => p.theme.borderRadius};
  546. border-bottom-right-radius: ${p => p.theme.borderRadius};
  547. font-size: ${p => p.theme.fontSizeMedium};
  548. box-shadow: none;
  549. `;
  550. const StyledErrorPanel = styled(ErrorPanel)`
  551. padding: ${space(2)};
  552. `;