useFocusArea.tsx 11 KB

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