chartBrush.tsx 8.5 KB

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