focusArea.tsx 8.5 KB

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