chart.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  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 {transformToAreaSeries} from 'sentry/components/charts/areaChart';
  8. import {transformToBarSeries} from 'sentry/components/charts/barChart';
  9. import BaseChart, {BaseChartProps} from 'sentry/components/charts/baseChart';
  10. import {transformToLineSeries} from 'sentry/components/charts/lineChart';
  11. import ScatterSeries from 'sentry/components/charts/series/scatterSeries';
  12. import {DateTimeObject} from 'sentry/components/charts/utils';
  13. import {ReactEchartsRef} from 'sentry/types/echarts';
  14. import mergeRefs from 'sentry/utils/mergeRefs';
  15. import {isCumulativeOp} from 'sentry/utils/metrics';
  16. import {formatMetricsUsingUnitAndOp} from 'sentry/utils/metrics/formatters';
  17. import {MetricCorrelation, MetricDisplayType} from 'sentry/utils/metrics/types';
  18. import useRouter from 'sentry/utils/useRouter';
  19. import {DDM_CHART_GROUP} from 'sentry/views/ddm/constants';
  20. import {FocusAreaProps} from 'sentry/views/ddm/context';
  21. import {useFocusArea} from 'sentry/views/ddm/focusArea';
  22. import {getFormatter} from '../../components/charts/components/tooltip';
  23. import {useMetricSamples} from './useMetricSamples';
  24. import {Sample, ScatterSeries as ScatterSeriesType, Series} from './widget';
  25. type ChartProps = {
  26. displayType: MetricDisplayType;
  27. series: Series[];
  28. widgetIndex: number;
  29. correlations?: MetricCorrelation[];
  30. focusArea?: FocusAreaProps;
  31. height?: number;
  32. highlightedSampleId?: string;
  33. onSampleClick?: (sample: Sample) => void;
  34. operation?: string;
  35. };
  36. // We need to enable canvas renderer for echarts before we use it here.
  37. // Once we use it in more places, this should probably move to a more global place
  38. // But for now we keep it here to not invluence the bundle size of the main chunks.
  39. echarts.use(CanvasRenderer);
  40. export const MetricChart = forwardRef<ReactEchartsRef, ChartProps>(
  41. (
  42. {
  43. series,
  44. displayType,
  45. operation,
  46. widgetIndex,
  47. focusArea,
  48. height,
  49. correlations,
  50. onSampleClick,
  51. highlightedSampleId,
  52. },
  53. forwardedRef
  54. ) => {
  55. const router = useRouter();
  56. const chartRef = useRef<ReactEchartsRef>(null);
  57. const handleZoom = useCallback(
  58. (range: DateTimeObject) => {
  59. Sentry.metrics.increment('ddm.enhance.zoom');
  60. updateDateTime(range, router, {save: true});
  61. },
  62. [router]
  63. );
  64. const focusAreaBrush = useFocusArea({
  65. ...focusArea,
  66. chartRef,
  67. opts: {
  68. widgetIndex,
  69. isDisabled: !focusArea?.onAdd || !handleZoom,
  70. useFullYAxis: isCumulativeOp(operation),
  71. },
  72. onZoom: handleZoom,
  73. });
  74. useEffect(() => {
  75. const echartsInstance = chartRef?.current?.getEchartsInstance();
  76. if (echartsInstance && !echartsInstance.group) {
  77. echartsInstance.group = DDM_CHART_GROUP;
  78. }
  79. });
  80. const unit = series[0]?.unit;
  81. const seriesToShow = useMemo(
  82. () =>
  83. series
  84. .filter(s => !s.hidden)
  85. .map(s => ({
  86. ...s,
  87. silent: true,
  88. })),
  89. [series]
  90. );
  91. const valueFormatter = useCallback(
  92. (value: number) => {
  93. return formatMetricsUsingUnitAndOp(value, unit, operation);
  94. },
  95. [unit, operation]
  96. );
  97. const samples = useMetricSamples({
  98. chartRef,
  99. correlations,
  100. onClick: onSampleClick,
  101. highlightedSampleId,
  102. operation,
  103. timeseries: series,
  104. });
  105. // TODO(ddm): This assumes that all series have the same bucket size
  106. const bucketSize = seriesToShow[0]?.data[1]?.name - seriesToShow[0]?.data[0]?.name;
  107. const isSubMinuteBucket = bucketSize < 60_000;
  108. const seriesLength = seriesToShow[0]?.data.length;
  109. const displayFogOfWar = isCumulativeOp(operation);
  110. const chartProps = useMemo(() => {
  111. const timeseriesFormatters = {
  112. valueFormatter,
  113. isGroupedByDate: true,
  114. bucketSize,
  115. showTimeInTooltip: true,
  116. addSecondsToTimeFormat: isSubMinuteBucket,
  117. limit: 10,
  118. filter: (_, seriesParam) => {
  119. return seriesParam?.axisId === 'xAxis';
  120. },
  121. };
  122. const heightOptions = height ? {height} : {autoHeightResize: true};
  123. return {
  124. ...heightOptions,
  125. ...focusAreaBrush.options,
  126. forwardedRef: mergeRefs([forwardedRef, chartRef]),
  127. series: seriesToShow,
  128. renderer: seriesToShow.length > 20 ? ('canvas' as const) : ('svg' as const),
  129. isGroupedByDate: true,
  130. colors: seriesToShow.map(s => s.color),
  131. grid: {top: 5, bottom: 0, left: 0, right: 0},
  132. onClick: samples.handleClick,
  133. tooltip: {
  134. formatter: (params, asyncTicket) => {
  135. if (focusAreaBrush.isDrawingRef.current) {
  136. return '';
  137. }
  138. const hoveredEchartElement = Array.from(
  139. document.querySelectorAll(':hover')
  140. ).find(element => {
  141. return element.classList.contains('echarts-for-react');
  142. });
  143. const isThisChartHovered = hoveredEchartElement === chartRef?.current?.ele;
  144. if (!isThisChartHovered) {
  145. return '';
  146. }
  147. if (params.seriesType === 'scatter') {
  148. return getFormatter(samples.formatters)(params, asyncTicket);
  149. }
  150. return getFormatter(timeseriesFormatters)(params, asyncTicket);
  151. },
  152. },
  153. yAxes: [
  154. {
  155. // used to find and convert datapoint to pixel position
  156. id: 'yAxis',
  157. axisLabel: {
  158. formatter: (value: number) => {
  159. return valueFormatter(value);
  160. },
  161. },
  162. },
  163. samples.yAxis,
  164. ],
  165. xAxes: [
  166. {
  167. // used to find and convert datapoint to pixel position
  168. id: 'xAxis',
  169. axisPointer: {
  170. snap: true,
  171. },
  172. },
  173. samples.xAxis,
  174. ],
  175. };
  176. }, [
  177. bucketSize,
  178. focusAreaBrush.options,
  179. focusAreaBrush.isDrawingRef,
  180. forwardedRef,
  181. isSubMinuteBucket,
  182. seriesToShow,
  183. height,
  184. samples.handleClick,
  185. samples.xAxis,
  186. samples.yAxis,
  187. samples.formatters,
  188. valueFormatter,
  189. ]);
  190. return (
  191. <ChartWrapper>
  192. {focusAreaBrush.overlay}
  193. <CombinedChart
  194. {...chartProps}
  195. displayType={displayType}
  196. scatterSeries={samples.series}
  197. />
  198. {displayFogOfWar && (
  199. <FogOfWar bucketSize={bucketSize} seriesLength={seriesLength} />
  200. )}
  201. </ChartWrapper>
  202. );
  203. }
  204. );
  205. type CombinedChartProps = BaseChartProps & {
  206. displayType: MetricDisplayType;
  207. series: Series[];
  208. scatterSeries?: ScatterSeriesType[];
  209. };
  210. function CombinedChart({
  211. displayType,
  212. series,
  213. scatterSeries = [],
  214. ...chartProps
  215. }: CombinedChartProps) {
  216. const combinedSeries = useMemo(() => {
  217. if (displayType === MetricDisplayType.LINE) {
  218. return [
  219. ...transformToLineSeries({series}),
  220. ...transformToScatterSeries({series: scatterSeries, displayType}),
  221. ];
  222. }
  223. if (displayType === MetricDisplayType.BAR) {
  224. return [
  225. ...transformToBarSeries({series, stacked: true, animation: false}),
  226. ...transformToScatterSeries({series: scatterSeries, displayType}),
  227. ];
  228. }
  229. if (displayType === MetricDisplayType.AREA) {
  230. return [
  231. ...transformToAreaSeries({series, stacked: true, colors: chartProps.colors}),
  232. ...transformToScatterSeries({series: scatterSeries, displayType}),
  233. ];
  234. }
  235. return [];
  236. }, [displayType, scatterSeries, series, chartProps.colors]);
  237. return <BaseChart {...chartProps} series={combinedSeries} />;
  238. }
  239. function transformToScatterSeries({
  240. series,
  241. displayType,
  242. }: {
  243. displayType: MetricDisplayType;
  244. series: Series[];
  245. }) {
  246. return series.map(({seriesName, data: seriesData, ...options}) => {
  247. if (displayType === MetricDisplayType.BAR) {
  248. return ScatterSeries({
  249. ...options,
  250. name: seriesName,
  251. data: seriesData?.map(({value, name}) => ({value: [name, value]})),
  252. });
  253. }
  254. return ScatterSeries({
  255. ...options,
  256. name: seriesName,
  257. data: seriesData?.map(({value, name}) => [name, value]),
  258. animation: false,
  259. });
  260. });
  261. }
  262. function FogOfWar({
  263. bucketSize,
  264. seriesLength,
  265. }: {
  266. bucketSize?: number;
  267. seriesLength?: number;
  268. }) {
  269. if (!bucketSize || !seriesLength) {
  270. return null;
  271. }
  272. const widthFactor = getWidthFactor(bucketSize);
  273. const fogOfWarWidth = widthFactor * bucketSize + 30_000;
  274. const seriesWidth = bucketSize * seriesLength;
  275. // If either of these are undefiend, NaN or 0 the result will be invalid
  276. if (!fogOfWarWidth || !seriesWidth) {
  277. return null;
  278. }
  279. const width = (fogOfWarWidth / seriesWidth) * 100;
  280. return <FogOfWarOverlay width={width ?? 0} />;
  281. }
  282. function getWidthFactor(bucketSize: number) {
  283. // In general, fog of war should cover the last bucket
  284. if (bucketSize > 30 * 60_000) {
  285. return 1;
  286. }
  287. // for 10s timeframe we want to show a fog of war that spans last 10 buckets
  288. // because on average, we are missing last 90 seconds of data
  289. if (bucketSize <= 10_000) {
  290. return 10;
  291. }
  292. // For smaller time frames we want to show a wider fog of war
  293. return 2;
  294. }
  295. const ChartWrapper = styled('div')`
  296. position: relative;
  297. height: 100%;
  298. `;
  299. const FogOfWarOverlay = styled('div')<{width?: number}>`
  300. height: calc(100% - 29px);
  301. width: ${p => p.width}%;
  302. position: absolute;
  303. right: 0px;
  304. top: 5px;
  305. pointer-events: none;
  306. background: linear-gradient(
  307. 90deg,
  308. ${p => p.theme.background}00 0%,
  309. ${p => p.theme.background}FF 70%,
  310. ${p => p.theme.background}FF 100%
  311. );
  312. `;