chart.tsx 9.7 KB

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