useFocusArea.tsx 11 KB

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