chart.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import {forwardRef, useCallback, useEffect, useMemo, useRef} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as Sentry from '@sentry/react';
  4. import * as echarts from 'echarts/core';
  5. import {CanvasRenderer} from 'echarts/renderers';
  6. import {updateDateTime} from 'sentry/actionCreators/pageFilters';
  7. import {AreaChart} from 'sentry/components/charts/areaChart';
  8. import {BarChart} from 'sentry/components/charts/barChart';
  9. import {LineChart} from 'sentry/components/charts/lineChart';
  10. import {DateTimeObject} from 'sentry/components/charts/utils';
  11. import {ReactEchartsRef} from 'sentry/types/echarts';
  12. import mergeRefs from 'sentry/utils/mergeRefs';
  13. import {
  14. formatMetricsUsingUnitAndOp,
  15. isCumulativeOp,
  16. MetricDisplayType,
  17. } from 'sentry/utils/metrics';
  18. import useRouter from 'sentry/utils/useRouter';
  19. import {DDM_CHART_GROUP} from 'sentry/views/ddm/constants';
  20. import {FocusArea, useFocusArea} from 'sentry/views/ddm/focusArea';
  21. import {getFormatter} from '../../components/charts/components/tooltip';
  22. import {Series} from './widget';
  23. type ChartProps = {
  24. displayType: MetricDisplayType;
  25. focusArea: FocusArea | null;
  26. series: Series[];
  27. widgetIndex: number;
  28. addFocusArea?: (area: FocusArea) => void;
  29. height?: number;
  30. operation?: string;
  31. removeFocusArea?: () => void;
  32. };
  33. // We need to enable canvas renderer for echarts before we use it here.
  34. // Once we use it in more places, this should probably move to a more global place
  35. // But for now we keep it here to not invluence the bundle size of the main chunks.
  36. echarts.use(CanvasRenderer);
  37. export const MetricChart = forwardRef<ReactEchartsRef, ChartProps>(
  38. (
  39. {
  40. series,
  41. displayType,
  42. operation,
  43. widgetIndex,
  44. addFocusArea,
  45. focusArea,
  46. removeFocusArea,
  47. height,
  48. },
  49. forwardedRef
  50. ) => {
  51. const router = useRouter();
  52. const chartRef = useRef<ReactEchartsRef>(null);
  53. const handleZoom = useCallback(
  54. (range: DateTimeObject) => {
  55. Sentry.metrics.increment('ddm.enhance.zoom');
  56. updateDateTime(range, router, {save: true});
  57. },
  58. [router]
  59. );
  60. const focusAreaBrush = useFocusArea(
  61. chartRef,
  62. focusArea,
  63. {
  64. widgetIndex,
  65. isDisabled: !addFocusArea || !removeFocusArea || !handleZoom,
  66. useFullYAxis: isCumulativeOp(operation),
  67. },
  68. addFocusArea,
  69. removeFocusArea,
  70. handleZoom
  71. );
  72. useEffect(() => {
  73. const echartsInstance = chartRef?.current?.getEchartsInstance();
  74. if (echartsInstance && !echartsInstance.group) {
  75. echartsInstance.group = DDM_CHART_GROUP;
  76. }
  77. });
  78. const unit = series[0]?.unit;
  79. const seriesToShow = useMemo(
  80. () =>
  81. series
  82. .filter(s => !s.hidden)
  83. .map(s => ({
  84. ...s,
  85. silent: true,
  86. })),
  87. [series]
  88. );
  89. // TODO(ddm): This assumes that all series have the same bucket size
  90. const bucketSize = seriesToShow[0]?.data[1]?.name - seriesToShow[0]?.data[0]?.name;
  91. const isSubMinuteBucket = bucketSize < 60_000;
  92. const seriesLength = seriesToShow[0]?.data.length;
  93. const displayFogOfWar = isCumulativeOp(operation);
  94. const chartProps = useMemo(() => {
  95. const formatters = {
  96. valueFormatter: (value: number) =>
  97. formatMetricsUsingUnitAndOp(value, unit, operation),
  98. isGroupedByDate: true,
  99. bucketSize,
  100. showTimeInTooltip: true,
  101. addSecondsToTimeFormat: isSubMinuteBucket,
  102. limit: 10,
  103. };
  104. const heightOptions = height ? {height} : {autoHeightResize: true};
  105. return {
  106. ...heightOptions,
  107. ...focusAreaBrush.options,
  108. forwardedRef: mergeRefs([forwardedRef, chartRef]),
  109. series: seriesToShow,
  110. renderer: seriesToShow.length > 20 ? ('canvas' as const) : ('svg' as const),
  111. isGroupedByDate: true,
  112. colors: seriesToShow.map(s => s.color),
  113. grid: {top: 5, bottom: 0, left: 0, right: 0},
  114. tooltip: {
  115. formatter: (params, asyncTicket) => {
  116. if (focusAreaBrush.isDrawingRef.current) {
  117. return '';
  118. }
  119. const hoveredEchartElement = Array.from(
  120. document.querySelectorAll(':hover')
  121. ).find(element => {
  122. return element.classList.contains('echarts-for-react');
  123. });
  124. if (hoveredEchartElement === chartRef?.current?.ele) {
  125. return getFormatter(formatters)(params, asyncTicket);
  126. }
  127. return '';
  128. },
  129. },
  130. yAxis: {
  131. // used to find and convert datapoint to pixel position
  132. id: 'yAxis',
  133. axisLabel: {
  134. formatter: (value: number) => {
  135. return formatMetricsUsingUnitAndOp(value, unit, operation);
  136. },
  137. },
  138. },
  139. xAxis: {
  140. // used to find and convert datapoint to pixel position
  141. id: 'xAxis',
  142. axisPointer: {
  143. snap: true,
  144. },
  145. },
  146. };
  147. }, [
  148. bucketSize,
  149. focusAreaBrush.options,
  150. focusAreaBrush.isDrawingRef,
  151. forwardedRef,
  152. isSubMinuteBucket,
  153. operation,
  154. seriesToShow,
  155. unit,
  156. height,
  157. ]);
  158. return (
  159. <ChartWrapper>
  160. {focusAreaBrush.overlay}
  161. {displayType === MetricDisplayType.LINE ? (
  162. <LineChart {...chartProps} />
  163. ) : displayType === MetricDisplayType.AREA ? (
  164. <AreaChart stacked {...chartProps} />
  165. ) : (
  166. <BarChart stacked animation={false} {...chartProps} />
  167. )}
  168. {displayFogOfWar && (
  169. <FogOfWar bucketSize={bucketSize} seriesLength={seriesLength} />
  170. )}
  171. </ChartWrapper>
  172. );
  173. }
  174. );
  175. function FogOfWar({
  176. bucketSize,
  177. seriesLength,
  178. }: {
  179. bucketSize?: number;
  180. seriesLength?: number;
  181. }) {
  182. if (!bucketSize || !seriesLength) {
  183. return null;
  184. }
  185. const widthFactor = getWidthFactor(bucketSize);
  186. const fogOfWarWidth = widthFactor * bucketSize + 30_000;
  187. const seriesWidth = bucketSize * seriesLength;
  188. // If either of these are undefiend, NaN or 0 the result will be invalid
  189. if (!fogOfWarWidth || !seriesWidth) {
  190. return null;
  191. }
  192. const width = (fogOfWarWidth / seriesWidth) * 100;
  193. return <FogOfWarOverlay width={width ?? 0} />;
  194. }
  195. function getWidthFactor(bucketSize: number) {
  196. // In general, fog of war should cover the last bucket
  197. if (bucketSize > 30 * 60_000) {
  198. return 1;
  199. }
  200. // for 10s timeframe we want to show a fog of war that spans last 10 buckets
  201. // because on average, we are missing last 90 seconds of data
  202. if (bucketSize <= 10_000) {
  203. return 10;
  204. }
  205. // For smaller time frames we want to show a wider fog of war
  206. return 2;
  207. }
  208. const ChartWrapper = styled('div')`
  209. position: relative;
  210. height: 100%;
  211. `;
  212. const FogOfWarOverlay = styled('div')<{width?: number}>`
  213. height: calc(100% - 29px);
  214. width: ${p => p.width}%;
  215. position: absolute;
  216. right: 0px;
  217. top: 5px;
  218. pointer-events: none;
  219. background: linear-gradient(
  220. 90deg,
  221. ${p => p.theme.background}00 0%,
  222. ${p => p.theme.background}FF 70%,
  223. ${p => p.theme.background}FF 100%
  224. );
  225. `;