focusArea.tsx 9.2 KB

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