chart.tsx 19 KB

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