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