useMetricChartSamples.tsx 8.5 KB

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