chart.tsx 18 KB

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