useFocusArea.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. import type {RefObject} from 'react';
  2. import {Fragment, useCallback, useEffect, useMemo, useRef, useState} from 'react';
  3. import {useTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import {useResizeObserver} from '@react-aria/utils';
  6. import color from 'color';
  7. import isEqual from 'lodash/isEqual';
  8. import moment from 'moment';
  9. import {Button} from 'sentry/components/button';
  10. import type {DateTimeObject} from 'sentry/components/charts/utils';
  11. import {IconClose, IconZoom} from 'sentry/icons';
  12. import {t} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import type {DateString} from 'sentry/types/core';
  15. import type {EChartBrushEndHandler, ReactEchartsRef} from 'sentry/types/echarts';
  16. import mergeRefs from 'sentry/utils/mergeRefs';
  17. import type {ValueRect} from 'sentry/views/metrics/chart/chartUtils';
  18. import {getValueRect} from 'sentry/views/metrics/chart/chartUtils';
  19. import type {
  20. CombinedMetricChartProps,
  21. FocusAreaSelection,
  22. SelectionRange,
  23. } from 'sentry/views/metrics/chart/types';
  24. import {
  25. SAMPLES_X_AXIS_ID,
  26. SAMPLES_Y_AXIS_ID,
  27. } from 'sentry/views/metrics/chart/useMetricChartSamples';
  28. import {CHART_HEIGHT} from 'sentry/views/metrics/constants';
  29. import type {FocusAreaProps} from 'sentry/views/metrics/context';
  30. interface AbsolutePosition {
  31. height: string;
  32. left: string;
  33. top: string;
  34. width: string;
  35. }
  36. interface UseFocusAreaOptions {
  37. widgetIndex: number;
  38. isDisabled?: boolean;
  39. useFullYAxis?: boolean;
  40. }
  41. export interface UseFocusAreaProps extends FocusAreaProps {
  42. chartRef: RefObject<ReactEchartsRef>;
  43. opts: UseFocusAreaOptions;
  44. scalingFactor: number;
  45. chartUnit?: string;
  46. onZoom?: (range: DateTimeObject) => void;
  47. sampleUnit?: string;
  48. }
  49. type BrushEndResult = Parameters<EChartBrushEndHandler>[0];
  50. export function useFocusArea({
  51. selection: selection,
  52. opts: {widgetIndex, isDisabled, useFullYAxis},
  53. onAdd,
  54. onDraw,
  55. onRemove,
  56. onZoom,
  57. }: UseFocusAreaProps) {
  58. const hasFocusArea = !isDisabled && selection && selection.widgetIndex === widgetIndex;
  59. const chartRef = useRef<ReactEchartsRef>(null);
  60. const chartElement = chartRef.current?.ele;
  61. const isDrawingRef = useRef(false);
  62. const theme = useTheme();
  63. const startBrush = useCallback(() => {
  64. if (hasFocusArea || isDisabled) {
  65. return;
  66. }
  67. onDraw?.();
  68. chartRef.current?.getEchartsInstance().dispatchAction({
  69. type: 'takeGlobalCursor',
  70. key: 'brush',
  71. brushOption: {
  72. brushType: 'rect',
  73. },
  74. });
  75. }, [chartRef, hasFocusArea, isDisabled, onDraw]);
  76. useEffect(() => {
  77. const handleMouseDown = () => {
  78. isDrawingRef.current = true;
  79. startBrush();
  80. };
  81. // Handle mouse up is called after onBrushEnd
  82. // We can use it for a final reliable cleanup as onBrushEnd is not always called (e.g. when simply clicking the chart)
  83. const handleMouseUp = () => {
  84. isDrawingRef.current = false;
  85. };
  86. chartElement?.addEventListener('mousedown', handleMouseDown, {capture: true});
  87. window.addEventListener('mouseup', handleMouseUp);
  88. return () => {
  89. chartElement?.removeEventListener('mousedown', handleMouseDown, {capture: true});
  90. window.removeEventListener('mouseup', handleMouseUp);
  91. };
  92. }, [chartElement, startBrush]);
  93. const onBrushEnd = useCallback(
  94. (brushEnd: BrushEndResult) => {
  95. if (isDisabled || !isDrawingRef.current) {
  96. return;
  97. }
  98. const rect = brushEnd.areas[0];
  99. if (!rect) {
  100. return;
  101. }
  102. const range = getSelectionRange(brushEnd, !!useFullYAxis, getValueRect(chartRef));
  103. onAdd?.({
  104. widgetIndex,
  105. range,
  106. });
  107. // Remove brush from echarts immediately after adding the focus area
  108. // since brushes get added to all charts in the group by default and then randomly
  109. // render in the wrong place
  110. chartRef.current?.getEchartsInstance().dispatchAction({
  111. type: 'brush',
  112. brushType: 'clear',
  113. areas: [],
  114. });
  115. isDrawingRef.current = false;
  116. },
  117. [isDisabled, useFullYAxis, onAdd, widgetIndex]
  118. );
  119. const handleRemove = useCallback(() => {
  120. onRemove?.();
  121. }, [onRemove]);
  122. const handleZoomIn = useCallback(() => {
  123. handleRemove();
  124. onZoom?.({
  125. period: null,
  126. ...selection?.range,
  127. });
  128. }, [selection, handleRemove, onZoom]);
  129. const applyChartProps = useCallback(
  130. (baseProps: CombinedMetricChartProps): CombinedMetricChartProps => {
  131. return {
  132. ...baseProps,
  133. forwardedRef: mergeRefs([baseProps.forwardedRef, chartRef]),
  134. tooltip: {
  135. formatter: (params, asyncTicket) => {
  136. // Deactivate tooltips while drawing
  137. if (isDrawingRef.current) {
  138. return '';
  139. }
  140. const baseFormatter = baseProps.tooltip?.formatter;
  141. if (typeof baseFormatter === 'string') {
  142. return baseFormatter;
  143. }
  144. if (!baseFormatter) {
  145. throw new Error(
  146. 'You need to define a tooltip formatter for the chart when using the focus area'
  147. );
  148. }
  149. return baseFormatter(params, asyncTicket);
  150. },
  151. },
  152. onBrushEnd,
  153. toolBox: {
  154. show: false,
  155. },
  156. brush: {
  157. toolbox: ['rect'],
  158. xAxisIndex: Array.isArray(baseProps.xAxes)
  159. ? baseProps.xAxes.findIndex(a => a?.id === SAMPLES_X_AXIS_ID)
  160. : 0,
  161. yAxisIndex: Array.isArray(baseProps.yAxes)
  162. ? baseProps.yAxes.findIndex(a => a?.id === SAMPLES_Y_AXIS_ID)
  163. : 0,
  164. brushStyle: {
  165. borderWidth: 2,
  166. borderColor: theme.gray500,
  167. color: 'transparent',
  168. },
  169. inBrush: {
  170. opacity: 1,
  171. },
  172. outOfBrush: {
  173. opacity: 1,
  174. },
  175. z: 10,
  176. },
  177. };
  178. },
  179. [onBrushEnd, theme.gray500]
  180. );
  181. return useMemo(
  182. () => ({
  183. applyChartProps,
  184. overlay: hasFocusArea ? (
  185. <FocusAreaOverlay
  186. rect={selection!}
  187. onRemove={handleRemove}
  188. onZoom={handleZoomIn}
  189. chartRef={chartRef}
  190. useFullYAxis={!!useFullYAxis}
  191. />
  192. ) : null,
  193. }),
  194. [applyChartProps, handleRemove, handleZoomIn, hasFocusArea, selection, useFullYAxis]
  195. );
  196. }
  197. export type UseFocusAreaResult = ReturnType<typeof useFocusArea>;
  198. type FocusAreaOverlayProps = {
  199. chartRef: RefObject<ReactEchartsRef>;
  200. onRemove: () => void;
  201. onZoom: () => void;
  202. rect: FocusAreaSelection | null;
  203. useFullYAxis: boolean;
  204. };
  205. function FocusAreaOverlay({
  206. rect,
  207. onZoom,
  208. onRemove,
  209. useFullYAxis,
  210. chartRef,
  211. }: FocusAreaOverlayProps) {
  212. const [position, setPosition] = useState<AbsolutePosition | null>(null);
  213. const wrapperRef = useRef<HTMLDivElement>(null);
  214. useResizeObserver({
  215. ref: wrapperRef,
  216. onResize: () => {
  217. const chartInstance = chartRef.current?.getEchartsInstance();
  218. chartInstance?.resize();
  219. updatePosition();
  220. },
  221. });
  222. const updatePosition = useCallback(() => {
  223. const chartInstance = chartRef.current?.getEchartsInstance();
  224. if (
  225. !rect ||
  226. !chartInstance ||
  227. rect.range.max === undefined ||
  228. rect.range.min === undefined ||
  229. rect.range.start === undefined ||
  230. rect.range.end === undefined
  231. ) {
  232. return;
  233. }
  234. const finder = {xAxisId: SAMPLES_X_AXIS_ID, yAxisId: SAMPLES_Y_AXIS_ID};
  235. const max = rect.range.max;
  236. const min = rect.range.min;
  237. const topLeft = chartInstance.convertToPixel(finder, [
  238. getTimestamp(rect.range.start),
  239. max,
  240. ]);
  241. const bottomRight = chartInstance.convertToPixel(finder, [
  242. getTimestamp(rect.range.end),
  243. min,
  244. ] as number[]);
  245. if (!topLeft || !bottomRight) {
  246. return;
  247. }
  248. const widthPx = bottomRight[0] - topLeft[0];
  249. const heightPx = bottomRight[1] - topLeft[1];
  250. const resultTop = useFullYAxis ? '0px' : `${topLeft[1].toPrecision(5)}px`;
  251. const resultHeight = useFullYAxis
  252. ? `${CHART_HEIGHT}px`
  253. : `${heightPx.toPrecision(5)}px`;
  254. // Ensure the focus area rect is always within the chart bounds
  255. const left = Math.max(topLeft[0], 0);
  256. const width = Math.min(widthPx, chartInstance.getWidth() - left);
  257. const newPosition = {
  258. left: `${left.toPrecision(5)}px`,
  259. top: resultTop,
  260. width: `${width.toPrecision(5)}px`,
  261. height: resultHeight,
  262. };
  263. if (!isEqual(newPosition, position)) {
  264. setPosition(newPosition);
  265. }
  266. }, [chartRef, rect, useFullYAxis, position]);
  267. useEffect(() => {
  268. // In some cases echarts is not yet done with updating the chart
  269. // and the sample axes are not yet available to read the position from
  270. // so we need to delay the update until the next microtask
  271. queueMicrotask(updatePosition);
  272. }, [rect, updatePosition]);
  273. if (!position) {
  274. return null;
  275. }
  276. const {left, top, width, height} = position;
  277. return (
  278. <Fragment>
  279. <FocusAreaWrapper ref={wrapperRef}>
  280. <FocusAreaRect top={top} left={left} width={width} height={height} />
  281. </FocusAreaWrapper>
  282. <FocusAreaRectActions top={top} rectHeight={height} left={left}>
  283. <Button
  284. size="xs"
  285. onClick={onZoom}
  286. icon={<IconZoom isZoomIn />}
  287. aria-label={t('zoom')}
  288. />
  289. <Button
  290. size="xs"
  291. onClick={onRemove}
  292. icon={<IconClose />}
  293. aria-label={t('remove')}
  294. />
  295. </FocusAreaRectActions>
  296. </Fragment>
  297. );
  298. }
  299. const getDateString = (timestamp: number): string =>
  300. moment.utc(timestamp).format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS);
  301. const getTimestamp = (date: DateString) => moment.utc(date).valueOf();
  302. const getSelectionRange = (
  303. params: BrushEndResult,
  304. useFullYAxis: boolean,
  305. boundingRect: ValueRect
  306. ): SelectionRange => {
  307. const rect = params.areas[0];
  308. const startTimestamp = Math.min(...rect.coordRange[0]);
  309. const endTimestamp = Math.max(...rect.coordRange[0]);
  310. const startDate = getDateString(Math.max(startTimestamp, boundingRect.xMin));
  311. const endDate = getDateString(Math.min(endTimestamp, boundingRect.xMax));
  312. const min = useFullYAxis ? NaN : Math.min(...rect.coordRange[1]);
  313. const max = useFullYAxis ? NaN : Math.max(...rect.coordRange[1]);
  314. return {
  315. start: startDate,
  316. end: endDate,
  317. min,
  318. max,
  319. };
  320. };
  321. const FocusAreaRectActions = styled('div')<{
  322. left: string;
  323. rectHeight: string;
  324. top: string;
  325. }>`
  326. position: absolute;
  327. top: calc(${p => p.top} + ${p => p.rectHeight});
  328. left: ${p => p.left};
  329. display: flex;
  330. gap: ${space(0.5)};
  331. padding: ${space(0.5)};
  332. z-index: 2;
  333. pointer-events: auto;
  334. `;
  335. const FocusAreaWrapper = styled('div')`
  336. position: absolute;
  337. top: 0px;
  338. left: 0;
  339. height: 100%;
  340. width: 100%;
  341. overflow: hidden;
  342. `;
  343. const FocusAreaRect = styled('div')<{
  344. height: string;
  345. left: string;
  346. top: string;
  347. width: string;
  348. }>`
  349. position: absolute;
  350. top: ${p => p.top};
  351. left: ${p => p.left};
  352. width: ${p => p.width};
  353. height: ${p => p.height};
  354. padding: ${space(1)};
  355. pointer-events: none;
  356. z-index: 1;
  357. border: 2px solid ${p => p.theme.gray500};
  358. border-radius: ${p => p.theme.borderRadius};
  359. /* requires overflow: hidden on FocusAreaWrapper */
  360. box-shadow: 0px 0px 0px 9999px ${p => color(p.theme.surface400).alpha(0.75).toString()};
  361. `;