chart.tsx 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import {useEffect, useRef} from 'react';
  2. import {Theme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {useHover} from '@react-aria/interactions';
  5. import {AreaChart} from 'sentry/components/charts/areaChart';
  6. import {BarChart} from 'sentry/components/charts/barChart';
  7. import ChartZoom from 'sentry/components/charts/chartZoom';
  8. import Legend from 'sentry/components/charts/components/legend';
  9. import {LineChart} from 'sentry/components/charts/lineChart';
  10. import ReleaseSeries from 'sentry/components/charts/releaseSeries';
  11. import {RELEASE_LINES_THRESHOLD} from 'sentry/components/charts/utils';
  12. import {t} from 'sentry/locale';
  13. import {DateString, PageFilters} from 'sentry/types';
  14. import {ReactEchartsRef} from 'sentry/types/echarts';
  15. import {formatMetricsUsingUnitAndOp, MetricDisplayType} from 'sentry/utils/metrics';
  16. import theme from 'sentry/utils/theme';
  17. import useRouter from 'sentry/utils/useRouter';
  18. import {DDM_CHART_GROUP} from 'sentry/views/ddm/constants';
  19. import {getFormatter} from '../../components/charts/components/tooltip';
  20. import {Series} from './widget';
  21. type ChartProps = {
  22. displayType: MetricDisplayType;
  23. environments: PageFilters['environments'];
  24. projects: PageFilters['projects'];
  25. series: Series[];
  26. end?: string;
  27. onZoom?: (start: DateString, end: DateString) => void;
  28. operation?: string;
  29. period?: string;
  30. start?: string;
  31. utc?: boolean;
  32. };
  33. export function MetricChart({
  34. series,
  35. displayType,
  36. start,
  37. end,
  38. period,
  39. utc,
  40. operation,
  41. projects,
  42. environments,
  43. onZoom,
  44. }: ChartProps) {
  45. const chartRef = useRef<ReactEchartsRef>(null);
  46. const router = useRouter();
  47. const {hoverProps, isHovered} = useHover({
  48. isDisabled: false,
  49. });
  50. // TODO(ddm): Try to do this in a more elegant way
  51. useEffect(() => {
  52. const echartsInstance = chartRef?.current?.getEchartsInstance();
  53. if (echartsInstance && !echartsInstance.group) {
  54. echartsInstance.group = DDM_CHART_GROUP;
  55. }
  56. });
  57. const unit = series[0]?.unit;
  58. const seriesToShow = series.filter(s => !s.hidden);
  59. // TODO(ddm): This assumes that all series have the same bucket size
  60. const bucketSize = seriesToShow[0]?.data[1]?.name - seriesToShow[0]?.data[0]?.name;
  61. const isSubMinuteBucket = bucketSize < 60_000;
  62. const seriesLength = seriesToShow[0]?.data.length;
  63. const formatters = {
  64. valueFormatter: (value: number) =>
  65. formatMetricsUsingUnitAndOp(value, unit, operation),
  66. isGroupedByDate: true,
  67. bucketSize,
  68. showTimeInTooltip: true,
  69. addSecondsToTimeFormat: isSubMinuteBucket,
  70. };
  71. const displayFogOfWar = operation && ['sum', 'count'].includes(operation);
  72. const chartProps = {
  73. forwardedRef: chartRef,
  74. isGroupedByDate: true,
  75. height: 300,
  76. colors: seriesToShow.map(s => s.color),
  77. grid: {top: 20, bottom: 20, left: 15, right: 25},
  78. tooltip: {
  79. formatter: (params, asyncTicket) => {
  80. const hoveredEchartElement = Array.from(document.querySelectorAll(':hover')).find(
  81. element => {
  82. return element.classList.contains('echarts-for-react');
  83. }
  84. );
  85. if (hoveredEchartElement === chartRef?.current?.ele) {
  86. return getFormatter(formatters)(params, asyncTicket);
  87. }
  88. return '';
  89. },
  90. },
  91. yAxis: {
  92. axisLabel: {
  93. formatter: (value: number) => {
  94. return formatMetricsUsingUnitAndOp(value, unit, operation);
  95. },
  96. },
  97. },
  98. };
  99. return (
  100. <ChartWrapper {...hoverProps}>
  101. <ChartZoom
  102. router={router}
  103. period={period}
  104. start={start}
  105. end={end}
  106. utc={utc}
  107. onZoom={zoomPeriod => {
  108. onZoom?.(zoomPeriod.start, zoomPeriod.end);
  109. }}
  110. >
  111. {zoomRenderProps => (
  112. <ReleaseSeries
  113. utc={utc}
  114. period={period}
  115. start={zoomRenderProps.start!}
  116. end={zoomRenderProps.end!}
  117. projects={projects}
  118. environments={environments}
  119. preserveQueryParams
  120. >
  121. {({releaseSeries}) => {
  122. const releaseSeriesData = releaseSeries?.[0]?.markLine?.data ?? [];
  123. const selected =
  124. releaseSeriesData?.length >= RELEASE_LINES_THRESHOLD
  125. ? {[t('Releases')]: false}
  126. : {};
  127. const legend = releaseSeriesData?.length
  128. ? Legend({
  129. itemGap: 20,
  130. top: 0,
  131. right: 20,
  132. data: releaseSeries.map(s => s.seriesName),
  133. theme: theme as Theme,
  134. selected,
  135. })
  136. : undefined;
  137. // Zoom render props are slowing down the chart rendering,
  138. // so we only pass them when the chart is hovered over
  139. const zoomProps = isHovered ? zoomRenderProps : {};
  140. const allProps = {
  141. ...chartProps,
  142. ...zoomProps,
  143. series: [...seriesToShow, ...releaseSeries],
  144. legend,
  145. };
  146. return displayType === MetricDisplayType.LINE ? (
  147. <LineChart {...allProps} />
  148. ) : displayType === MetricDisplayType.AREA ? (
  149. <AreaChart {...allProps} />
  150. ) : (
  151. <BarChart stacked animation={false} {...allProps} />
  152. );
  153. }}
  154. </ReleaseSeries>
  155. )}
  156. </ChartZoom>
  157. {displayFogOfWar && (
  158. <FogOfWar bucketSize={bucketSize} seriesLength={seriesLength} />
  159. )}
  160. </ChartWrapper>
  161. );
  162. }
  163. function FogOfWar({
  164. bucketSize,
  165. seriesLength,
  166. }: {
  167. bucketSize?: number;
  168. seriesLength?: number;
  169. }) {
  170. if (!bucketSize || !seriesLength) {
  171. return null;
  172. }
  173. const widthFactor = getWidthFactor(bucketSize);
  174. const fogOfWarWidth = widthFactor * bucketSize + 30_000;
  175. const seriesWidth = bucketSize * seriesLength;
  176. // If either of these are undefiend, NaN or 0 the result will be invalid
  177. if (!fogOfWarWidth || !seriesWidth) {
  178. return null;
  179. }
  180. const width = (fogOfWarWidth / seriesWidth) * 100;
  181. return <FogOfWarOverlay width={width ?? 0} />;
  182. }
  183. function getWidthFactor(bucketSize: number) {
  184. // In general, fog of war should cover the last bucket
  185. if (bucketSize > 30 * 60_000) {
  186. return 1;
  187. }
  188. // for 10s timeframe we want to show a fog of war that spans last 10 buckets
  189. // because on average, we are missing last 90 seconds of data
  190. if (bucketSize <= 10_000) {
  191. return 10;
  192. }
  193. // For smaller time frames we want to show a wider fog of war
  194. return 2;
  195. }
  196. const ChartWrapper = styled('div')`
  197. position: relative;
  198. height: 300px;
  199. `;
  200. const FogOfWarOverlay = styled('div')<{width?: number}>`
  201. height: 244px;
  202. width: ${p => p.width}%;
  203. position: absolute;
  204. right: 21px;
  205. top: 18px;
  206. pointer-events: none;
  207. background: linear-gradient(
  208. 90deg,
  209. ${p => p.theme.background}00 0%,
  210. ${p => p.theme.background}FF 70%,
  211. ${p => p.theme.background}FF 100%
  212. );
  213. `;