focusArea.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  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 type {EChartsOption} from 'echarts';
  8. import moment from 'moment';
  9. import {Button} from 'sentry/components/button';
  10. import {IconClose, IconZoom} from 'sentry/icons';
  11. import {space} from 'sentry/styles/space';
  12. import type {EChartBrushEndHandler, ReactEchartsRef} from 'sentry/types/echarts';
  13. import type {SelectionRange} from 'sentry/utils/metrics/types';
  14. import {isInRect} from 'sentry/views/ddm/chartUtils';
  15. import {CHART_HEIGHT} from 'sentry/views/ddm/constants';
  16. import type {FocusAreaProps} from 'sentry/views/ddm/context';
  17. import type {DateTimeObject} from '../../components/charts/utils';
  18. interface AbsolutePosition {
  19. height: string;
  20. left: string;
  21. top: string;
  22. width: string;
  23. }
  24. interface UseFocusAreaOptions {
  25. widgetIndex: number;
  26. isDisabled?: boolean;
  27. useFullYAxis?: boolean;
  28. }
  29. export interface FocusAreaSelection {
  30. range: SelectionRange;
  31. widgetIndex: number;
  32. }
  33. export interface UseFocusAreaProps extends FocusAreaProps {
  34. chartRef: RefObject<ReactEchartsRef>;
  35. opts: UseFocusAreaOptions;
  36. onZoom?: (range: DateTimeObject) => void;
  37. }
  38. type BrushEndResult = Parameters<EChartBrushEndHandler>[0];
  39. export function useFocusArea({
  40. chartRef,
  41. selection: selection,
  42. opts: {widgetIndex, isDisabled, useFullYAxis},
  43. onAdd,
  44. onDraw,
  45. onRemove,
  46. onZoom,
  47. }: UseFocusAreaProps) {
  48. const hasFocusArea = useMemo(
  49. () => selection && selection.widgetIndex === widgetIndex,
  50. [selection, widgetIndex]
  51. );
  52. const isDrawingRef = useRef(false);
  53. const theme = useTheme();
  54. const startBrush = useCallback(() => {
  55. if (hasFocusArea || isDisabled) {
  56. return;
  57. }
  58. onDraw?.();
  59. chartRef.current?.getEchartsInstance().dispatchAction({
  60. type: 'takeGlobalCursor',
  61. key: 'brush',
  62. brushOption: {
  63. brushType: 'rect',
  64. },
  65. });
  66. isDrawingRef.current = true;
  67. }, [chartRef, hasFocusArea, isDisabled, onDraw]);
  68. useEffect(() => {
  69. const handleMouseDown = event => {
  70. const rect = chartRef.current?.ele.getBoundingClientRect();
  71. if (isInRect(event.clientX, event.clientY, rect)) {
  72. startBrush();
  73. }
  74. };
  75. window.addEventListener('mousedown', handleMouseDown, {capture: true});
  76. return () => {
  77. window.removeEventListener('mousedown', handleMouseDown, {capture: true});
  78. };
  79. }, [chartRef, startBrush]);
  80. const onBrushEnd = useCallback(
  81. (brushEnd: BrushEndResult) => {
  82. if (isDisabled || !isDrawingRef.current) {
  83. return;
  84. }
  85. const rect = brushEnd.areas[0];
  86. if (!rect) {
  87. return;
  88. }
  89. onAdd?.({
  90. widgetIndex,
  91. range: getSelectionRange(brushEnd, !!useFullYAxis),
  92. });
  93. // Remove brush from echarts immediately after adding the focus area
  94. // since brushes get added to all charts in the group by default and then randomly
  95. // render in the wrong place
  96. chartRef.current?.getEchartsInstance().dispatchAction({
  97. type: 'brush',
  98. brushType: 'clear',
  99. areas: [],
  100. });
  101. isDrawingRef.current = false;
  102. },
  103. [chartRef, isDisabled, onAdd, widgetIndex, useFullYAxis]
  104. );
  105. const handleRemove = useCallback(() => {
  106. onRemove?.();
  107. }, [onRemove]);
  108. const handleZoomIn = useCallback(() => {
  109. onZoom?.({
  110. period: null,
  111. ...selection?.range,
  112. });
  113. handleRemove();
  114. }, [selection, handleRemove, onZoom]);
  115. const brushOptions = useMemo(() => {
  116. return {
  117. onBrushEnd,
  118. toolBox: {
  119. show: false,
  120. },
  121. brush: {
  122. toolbox: ['rect'],
  123. xAxisIndex: 0,
  124. brushStyle: {
  125. borderWidth: 2,
  126. borderColor: theme.gray500,
  127. color: 'transparent',
  128. },
  129. inBrush: {
  130. opacity: 1,
  131. },
  132. outOfBrush: {
  133. opacity: 1,
  134. },
  135. z: 10,
  136. } as EChartsOption['brush'],
  137. };
  138. }, [onBrushEnd, theme.gray500]);
  139. if (hasFocusArea) {
  140. return {
  141. overlay: (
  142. <BrushRectOverlay
  143. rect={selection!}
  144. onRemove={handleRemove}
  145. onZoom={handleZoomIn}
  146. chartRef={chartRef}
  147. useFullYAxis={!!useFullYAxis}
  148. />
  149. ),
  150. isDrawingRef,
  151. options: {},
  152. };
  153. }
  154. return {
  155. overlay: null,
  156. isDrawingRef,
  157. options: brushOptions,
  158. };
  159. }
  160. type BrushRectOverlayProps = {
  161. chartRef: RefObject<ReactEchartsRef>;
  162. onRemove: () => void;
  163. onZoom: () => void;
  164. rect: FocusAreaSelection | null;
  165. useFullYAxis: boolean;
  166. };
  167. function BrushRectOverlay({
  168. rect,
  169. onZoom,
  170. onRemove,
  171. useFullYAxis,
  172. chartRef,
  173. }: BrushRectOverlayProps) {
  174. const chartInstance = chartRef.current?.getEchartsInstance();
  175. const [position, setPosition] = useState<AbsolutePosition | null>(null);
  176. const wrapperRef = useRef<HTMLDivElement>(null);
  177. useResizeObserver({
  178. ref: wrapperRef,
  179. onResize: () => {
  180. chartInstance?.resize();
  181. updatePosition();
  182. },
  183. });
  184. const updatePosition = useCallback(() => {
  185. if (!rect || !chartInstance) {
  186. return;
  187. }
  188. const finder = {xAxisId: 'xAxis', yAxisId: 'yAxis'};
  189. const topLeft = chartInstance.convertToPixel(finder, [
  190. getTimestamp(rect.range.start),
  191. rect.range.max,
  192. ] as number[]);
  193. const bottomRight = chartInstance.convertToPixel(finder, [
  194. getTimestamp(rect.range.end),
  195. rect.range.min,
  196. ] as number[]);
  197. if (!topLeft || !bottomRight) {
  198. return;
  199. }
  200. const widthPx = bottomRight[0] - topLeft[0];
  201. const heightPx = bottomRight[1] - topLeft[1];
  202. const resultTop = useFullYAxis ? '0px' : `${topLeft[1].toPrecision(5)}px`;
  203. const resultHeight = useFullYAxis
  204. ? `${CHART_HEIGHT}px`
  205. : `${heightPx.toPrecision(5)}px`;
  206. // Ensure the focus area rect is always within the chart bounds
  207. const left = Math.max(topLeft[0], 0);
  208. const width = Math.min(widthPx, chartInstance.getWidth() - left);
  209. setPosition({
  210. left: `${left.toPrecision(5)}px`,
  211. top: resultTop,
  212. width: `${width.toPrecision(5)}px`,
  213. height: resultHeight,
  214. });
  215. }, [rect, chartInstance, useFullYAxis]);
  216. useEffect(() => {
  217. updatePosition();
  218. }, [rect, updatePosition]);
  219. if (!position) {
  220. return null;
  221. }
  222. const {left, top, width, height} = position;
  223. return (
  224. <Fragment>
  225. <FocusAreaWrapper ref={wrapperRef}>
  226. <FocusAreaRect top={top} left={left} width={width} height={height} />
  227. </FocusAreaWrapper>
  228. <FocusAreaRectActions top={top} rectHeight={height} left={left}>
  229. <Button
  230. size="xs"
  231. onClick={onZoom}
  232. icon={<IconZoom isZoomIn />}
  233. aria-label="zoom"
  234. />
  235. <Button size="xs" onClick={onRemove} icon={<IconClose />} aria-label="remove" />
  236. </FocusAreaRectActions>
  237. </Fragment>
  238. );
  239. }
  240. const getDate = date =>
  241. date ? moment.utc(date).format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS) : null;
  242. const getTimestamp = date => (date ? moment.utc(date).valueOf() : null);
  243. const getSelectionRange = (
  244. params: BrushEndResult,
  245. useFullYAxis: boolean
  246. ): SelectionRange => {
  247. const rect = params.areas[0];
  248. const startTimestamp = Math.min(...rect.coordRange[0]);
  249. const endTimestamp = Math.max(...rect.coordRange[0]);
  250. const startDate = getDate(startTimestamp);
  251. const endDate = getDate(endTimestamp);
  252. const min = useFullYAxis ? NaN : Math.min(...rect.coordRange[1]);
  253. const max = useFullYAxis ? NaN : Math.max(...rect.coordRange[1]);
  254. return {
  255. start: startDate,
  256. end: endDate,
  257. min,
  258. max,
  259. };
  260. };
  261. const FocusAreaRectActions = styled('div')<{
  262. left: string;
  263. rectHeight: string;
  264. top: string;
  265. }>`
  266. position: absolute;
  267. top: calc(${p => p.top} + ${p => p.rectHeight});
  268. left: ${p => p.left};
  269. display: flex;
  270. gap: ${space(0.5)};
  271. padding: ${space(0.5)};
  272. z-index: 2;
  273. pointer-events: auto;
  274. `;
  275. const FocusAreaWrapper = styled('div')`
  276. position: absolute;
  277. top: 0px;
  278. left: 0;
  279. height: 100%;
  280. width: 100%;
  281. overflow: hidden;
  282. `;
  283. const FocusAreaRect = styled('div')<{
  284. height: string;
  285. left: string;
  286. top: string;
  287. width: string;
  288. }>`
  289. position: absolute;
  290. top: ${p => p.top};
  291. left: ${p => p.left};
  292. width: ${p => p.width};
  293. height: ${p => p.height};
  294. padding: ${space(1)};
  295. pointer-events: none;
  296. z-index: 1;
  297. border: 2px solid ${p => p.theme.gray500};
  298. border-radius: ${p => p.theme.borderRadius};
  299. /* requires overflow: hidden on FocusAreaWrapper */
  300. box-shadow: 0px 0px 0px 9999px ${p => color(p.theme.surface400).alpha(0.75).toString()};
  301. `;