chartBrush.tsx 7.4 KB

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