chart.tsx 10 KB

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