focusArea.tsx 8.4 KB

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