chart.tsx 6.8 KB

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