chart.tsx 10 KB

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