useMetricChartSamples.tsx 8.2 KB

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