chartBrush.tsx 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. import {RefObject, useCallback, useMemo} 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, IconStack, IconZoom} from 'sentry/icons';
  7. import {t} from 'sentry/locale';
  8. import {space} from 'sentry/styles/space';
  9. import {EChartBrushEndHandler, ReactEchartsRef} from 'sentry/types/echarts';
  10. import {getUtcToLocalDateObject} from 'sentry/utils/dates';
  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. datapoints: {
  21. x: number[];
  22. y: number[];
  23. };
  24. position: AbsolutePosition;
  25. widgetIndex: number;
  26. }
  27. interface UseFocusAreaBrushOptions {
  28. widgetIndex: number;
  29. isDisabled?: boolean;
  30. }
  31. type BrushEndResult = Parameters<EChartBrushEndHandler>[0];
  32. export function useFocusAreaBrush(
  33. chartRef: RefObject<ReactEchartsRef>,
  34. focusArea: FocusArea | null,
  35. onAdd: (area: FocusArea) => void,
  36. onRemove: () => void,
  37. onZoom: (range: DateTimeObject) => void,
  38. {widgetIndex, isDisabled = false}: UseFocusAreaBrushOptions
  39. ) {
  40. const onBrushEnd = useCallback(
  41. (brushEnd: BrushEndResult) => {
  42. if (isDisabled) {
  43. return;
  44. }
  45. const rect = brushEnd.areas[0];
  46. if (!rect) {
  47. return;
  48. }
  49. const chartWidth = chartRef.current?.getEchartsInstance().getWidth() ?? 100;
  50. const position = getPosition(brushEnd, chartWidth);
  51. onAdd({
  52. widgetIndex,
  53. position,
  54. datapoints: {
  55. x: rect.coordRange[0],
  56. y: rect.coordRange[1],
  57. },
  58. });
  59. },
  60. [chartRef, isDisabled, onAdd, widgetIndex]
  61. );
  62. const startBrush = useCallback(() => {
  63. chartRef.current?.getEchartsInstance().dispatchAction({
  64. type: 'takeGlobalCursor',
  65. key: 'brush',
  66. brushOption: {
  67. brushType: 'rect',
  68. },
  69. });
  70. }, [chartRef]);
  71. const handleZoomIn = useCallback(() => {
  72. const startFormatted = getDate(focusArea?.datapoints.x[0]);
  73. const endFormatted = getDate(focusArea?.datapoints.x[1]);
  74. onZoom({
  75. period: null,
  76. start: startFormatted ? getUtcToLocalDateObject(startFormatted) : startFormatted,
  77. end: endFormatted ? getUtcToLocalDateObject(endFormatted) : endFormatted,
  78. });
  79. onRemove();
  80. }, [focusArea, onRemove, onZoom]);
  81. const renderOverlay = focusArea && focusArea.widgetIndex === widgetIndex;
  82. const brushOptions = useMemo(() => {
  83. return {
  84. onBrushEnd,
  85. toolBox: {
  86. show: false,
  87. },
  88. brush: {
  89. toolbox: ['rect'],
  90. xAxisIndex: 0,
  91. brushStyle: {
  92. borderWidth: 2,
  93. borderColor: theme.purple300,
  94. color: 'transparent',
  95. },
  96. z: 10,
  97. } as EChartsOption['brush'],
  98. };
  99. }, [onBrushEnd]);
  100. if (renderOverlay) {
  101. return {
  102. overlay: (
  103. <BrushRectOverlay rect={focusArea} onRemove={onRemove} onZoom={handleZoomIn} />
  104. ),
  105. startBrush,
  106. options: {},
  107. };
  108. }
  109. return {
  110. overlay: null,
  111. startBrush,
  112. options: brushOptions,
  113. };
  114. }
  115. function BrushRectOverlay({rect, onZoom, onRemove}) {
  116. if (!rect) {
  117. return null;
  118. }
  119. const {top, left, width, height} = rect.position;
  120. return (
  121. <FocusAreaRect top={top} left={left} width={width} height={height}>
  122. <FocusAreaRectActions top={height}>
  123. <Button size="xs" disabled icon={<IconStack />}>
  124. {t('Show samples')}
  125. </Button>
  126. <Button
  127. size="xs"
  128. onClick={onZoom}
  129. icon={<IconZoom isZoomIn />}
  130. aria-label="zoom"
  131. />
  132. <Button size="xs" onClick={onRemove} icon={<IconDelete />} aria-label="remove" />
  133. </FocusAreaRectActions>
  134. </FocusAreaRect>
  135. );
  136. }
  137. const getDate = date =>
  138. date ? moment.utc(date).format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS) : null;
  139. const getPosition = (params: BrushEndResult, chartWidth: number): AbsolutePosition => {
  140. const rect = params.areas[0];
  141. if (!rect) {
  142. return {
  143. left: '0',
  144. top: '0',
  145. width: '0',
  146. height: '0',
  147. };
  148. }
  149. const left = rect.range[0][0];
  150. const width = rect.range[0][1] - left;
  151. const leftPercentage = (left / chartWidth) * 100;
  152. const widthPercentage = (width / chartWidth) * 100;
  153. const topPx = Math.min(...rect.range[1]);
  154. const heightPx = Math.max(...rect.range[1]) - topPx;
  155. return {
  156. left: `${leftPercentage.toPrecision(3)}%`,
  157. top: `${topPx}px`,
  158. width: `${widthPercentage.toPrecision(3)}%`,
  159. height: `${heightPx}px`,
  160. };
  161. };
  162. const FocusAreaRectActions = styled('div')<{
  163. top: string;
  164. }>`
  165. position: absolute;
  166. top: ${p => p.top};
  167. display: flex;
  168. gap: ${space(0.5)};
  169. padding: ${space(1)};
  170. z-index: 2;
  171. pointer-events: auto;
  172. `;
  173. const FocusAreaRect = styled('div')<{
  174. height: string;
  175. left: string;
  176. top: string;
  177. width: string;
  178. }>`
  179. position: absolute;
  180. top: ${p => p.top};
  181. left: ${p => p.left};
  182. width: ${p => p.width};
  183. height: ${p => p.height};
  184. outline: 2px solid ${p => p.theme.purple300};
  185. outline-offset: -1px;
  186. padding: ${space(1)};
  187. pointer-events: none;
  188. `;