focusArea.tsx 9.9 KB

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