chart.tsx 9.9 KB

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