useMetricChartSamples.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import type {XAXisOption, YAXisOption} from 'echarts/types/dist/shared';
  4. import moment from 'moment';
  5. import {getFormatter} from 'sentry/components/charts/components/tooltip';
  6. import {isChartHovered} from 'sentry/components/charts/utils';
  7. import type {Field} from 'sentry/components/metrics/metricSamplesTable';
  8. import {t} from 'sentry/locale';
  9. import type {EChartClickHandler, ReactEchartsRef} from 'sentry/types/echarts';
  10. import {defined} from 'sentry/utils';
  11. import mergeRefs from 'sentry/utils/mergeRefs';
  12. import {isCumulativeOp} from 'sentry/utils/metrics';
  13. import {formatMetricsUsingUnitAndOp} from 'sentry/utils/metrics/formatters';
  14. import {
  15. getSummaryValueForOp,
  16. type MetricsSamplesResults,
  17. } from 'sentry/utils/metrics/useMetricsSamples';
  18. import {fitToValueRect} from 'sentry/views/metrics/chart/chartUtils';
  19. import type {
  20. CombinedMetricChartProps,
  21. ScatterSeries,
  22. Series,
  23. } from 'sentry/views/metrics/chart/types';
  24. export const SAMPLES_X_AXIS_ID = 'xAxisSamples';
  25. export const SAMPLES_Y_AXIS_ID = 'yAxisSamples';
  26. function getValueRectFromSeries(series: Series[]) {
  27. const referenceSeries = series[0];
  28. if (!referenceSeries) {
  29. return {xMin: -Infinity, xMax: Infinity, yMin: -Infinity, yMax: Infinity};
  30. }
  31. const seriesWithSameUnit = series.filter(
  32. s => s.unit === referenceSeries.unit && !s.hidden
  33. );
  34. const scalingFactor = referenceSeries.scalingFactor ?? 1;
  35. const xValues = referenceSeries.data.map(entry => entry.name);
  36. const yValues = [referenceSeries, ...seriesWithSameUnit].flatMap(s =>
  37. s.data.map(entry => entry.value)
  38. );
  39. return {
  40. xMin: Math.min(...xValues),
  41. xMax: Math.max(...xValues),
  42. yMin: Math.min(0, ...yValues) / scalingFactor,
  43. yMax: Math.max(0, ...yValues) / scalingFactor,
  44. };
  45. }
  46. // TODO: remove this once we have a stabilized type for this
  47. type EChartMouseEventParam = Parameters<EChartClickHandler>[0];
  48. interface UseMetricChartSamplesOptions {
  49. timeseries: Series[];
  50. highlightedSampleId?: string;
  51. onSampleClick?: (sample: MetricsSamplesResults<Field>['data'][number]) => void;
  52. operation?: string;
  53. samples?: MetricsSamplesResults<Field>['data'];
  54. unit?: string;
  55. }
  56. export function useMetricChartSamples({
  57. timeseries,
  58. highlightedSampleId,
  59. onSampleClick,
  60. operation,
  61. samples,
  62. unit = '',
  63. }: UseMetricChartSamplesOptions) {
  64. const theme = useTheme();
  65. const chartRef = useRef<ReactEchartsRef>(null);
  66. const [valueRect, setValueRect] = useState(() => getValueRectFromSeries(timeseries));
  67. const samplesById = useMemo(() => {
  68. return (samples ?? []).reduce((acc, sample) => {
  69. acc[sample.id] = sample;
  70. return acc;
  71. }, {});
  72. }, [samples]);
  73. useEffect(() => {
  74. // Changes in timeseries change the valueRect since the timeseries yAxis auto scales
  75. // and scatter yAxis needs to match the scale
  76. setValueRect(getValueRectFromSeries(timeseries));
  77. }, [timeseries]);
  78. const xAxis: XAXisOption = useMemo(() => {
  79. return {
  80. id: SAMPLES_X_AXIS_ID,
  81. show: false,
  82. axisLabel: {
  83. show: false,
  84. },
  85. axisPointer: {
  86. type: 'none',
  87. },
  88. min: valueRect.xMin,
  89. max: valueRect.xMax,
  90. };
  91. }, [valueRect.xMin, valueRect.xMax]);
  92. const yAxis: YAXisOption = useMemo(() => {
  93. return {
  94. id: SAMPLES_Y_AXIS_ID,
  95. show: false,
  96. axisLabel: {
  97. show: false,
  98. },
  99. min: valueRect.yMin,
  100. max: valueRect.yMax,
  101. };
  102. }, [valueRect.yMin, valueRect.yMax]);
  103. const formatterOptions = useMemo(() => {
  104. return {
  105. isGroupedByDate: true,
  106. limit: 1,
  107. showTimeInTooltip: true,
  108. addSecondsToTimeFormat: true,
  109. nameFormatter: (name: string) => {
  110. return t('Span %s', name.substring(0, 8));
  111. },
  112. valueFormatter: (_, label?: string) => {
  113. // We need to access the sample as the charts datapoints are fit to the charts viewport
  114. const sample = samplesById[label ?? ''];
  115. const yValue = getSummaryValueForOp(sample.summary, operation);
  116. return formatMetricsUsingUnitAndOp(yValue, unit, operation);
  117. },
  118. };
  119. }, [operation, samplesById, unit]);
  120. const handleClick = useCallback<EChartClickHandler>(
  121. (event: EChartMouseEventParam) => {
  122. const sample = samplesById[event.seriesName];
  123. if (defined(onSampleClick) && defined(sample)) {
  124. onSampleClick(sample);
  125. }
  126. },
  127. [onSampleClick, samplesById]
  128. );
  129. const applyChartProps = useCallback(
  130. (baseProps: CombinedMetricChartProps): CombinedMetricChartProps => {
  131. let series: ScatterSeries[] = [];
  132. const newYAxisIndex = Array.isArray(baseProps.yAxes) ? baseProps.yAxes.length : 1;
  133. const newXAxisIndex = Array.isArray(baseProps.xAxes) ? baseProps.xAxes.length : 1;
  134. if (!isCumulativeOp(operation)) {
  135. series = (samples ?? []).map((sample, index) => {
  136. const isHighlighted = highlightedSampleId === sample.id;
  137. const xValue = moment(sample.timestamp).valueOf();
  138. const value = getSummaryValueForOp(sample.summary, operation);
  139. const yValue = value;
  140. const [xPosition, yPosition] = fitToValueRect(xValue, yValue, valueRect);
  141. return {
  142. seriesName: sample.id,
  143. id: `${index}_${sample.id}`,
  144. operation: '',
  145. unit: '',
  146. symbolSize: isHighlighted ? 20 : 11,
  147. animation: false,
  148. symbol: yPosition === yValue ? 'circle' : 'arrow',
  149. symbolRotate: yPosition > yValue ? 180 : 0,
  150. color: theme.purple400,
  151. itemStyle: {
  152. color: theme.purple400,
  153. opacity: 0.95,
  154. borderColor: theme.white,
  155. borderWidth: 1,
  156. },
  157. yAxisIndex: newYAxisIndex,
  158. xAxisIndex: newXAxisIndex,
  159. xValue,
  160. yValue,
  161. total: yValue,
  162. tooltip: {
  163. axisPointer: {
  164. type: 'none',
  165. },
  166. },
  167. data: [
  168. {
  169. name: xPosition,
  170. value: yPosition,
  171. },
  172. ],
  173. z: baseProps.series.length + 1,
  174. };
  175. });
  176. }
  177. return {
  178. ...baseProps,
  179. forwardedRef: mergeRefs([baseProps.forwardedRef, chartRef]),
  180. scatterSeries: series,
  181. xAxes: [...(Array.isArray(baseProps.xAxes) ? baseProps.xAxes : []), xAxis],
  182. yAxes: [...(Array.isArray(baseProps.yAxes) ? baseProps.yAxes : []), yAxis],
  183. onClick: (...args) => {
  184. handleClick(...args);
  185. baseProps.onClick?.(...args);
  186. },
  187. tooltip: {
  188. formatter: (params: any, asyncTicket) => {
  189. // Only show the tooltip if the current chart is hovered
  190. // as chart groups trigger the tooltip for all charts in the group when one is hoverered
  191. if (!isChartHovered(chartRef?.current)) {
  192. return '';
  193. }
  194. // Hovering a single correlated sample datapoint
  195. if (params.seriesType === 'scatter') {
  196. return getFormatter(formatterOptions)(params, asyncTicket);
  197. }
  198. const baseFormatter = baseProps.tooltip?.formatter;
  199. if (typeof baseFormatter === 'string') {
  200. return baseFormatter;
  201. }
  202. if (!baseFormatter) {
  203. throw new Error(
  204. 'You need to define a tooltip formatter for the chart when using metric samples'
  205. );
  206. }
  207. return baseFormatter(params, asyncTicket);
  208. },
  209. },
  210. };
  211. },
  212. [
  213. formatterOptions,
  214. handleClick,
  215. highlightedSampleId,
  216. operation,
  217. samples,
  218. theme.purple400,
  219. theme.white,
  220. valueRect,
  221. xAxis,
  222. yAxis,
  223. ]
  224. );
  225. return useMemo(() => {
  226. if (!defined(samples)) {
  227. return undefined;
  228. }
  229. return {applyChartProps};
  230. }, [applyChartProps, samples]);
  231. }
  232. export type UseMetricSamplesResult = ReturnType<typeof useMetricChartSamples>;