useFocusArea.tsx 11 KB

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