chartBrush.tsx 5.8 KB

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