import { Fragment, RefObject, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {useResizeObserver} from '@react-aria/utils'; import color from 'color'; import {EChartsOption} from 'echarts'; import moment from 'moment'; import {Button} from 'sentry/components/button'; import {IconClose, IconZoom} from 'sentry/icons'; import {space} from 'sentry/styles/space'; import {EChartBrushEndHandler, ReactEchartsRef} from 'sentry/types/echarts'; import {MetricRange} from 'sentry/utils/metrics'; import {DateTimeObject} from '../../components/charts/utils'; interface AbsolutePosition { height: string; left: string; top: string; width: string; } export interface FocusArea { range: MetricRange; widgetIndex: number; } interface UseFocusAreaOptions { widgetIndex: number; isDisabled?: boolean; useFullYAxis?: boolean; } type BrushEndResult = Parameters[0]; function isInRect(x: number, y: number, rect: DOMRect | undefined) { if (!rect) { return false; } return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; } export function useFocusArea( chartRef: RefObject, focusArea: FocusArea | null, {widgetIndex, isDisabled = false, useFullYAxis = false}: UseFocusAreaOptions, onAdd: (area: FocusArea) => void = () => {}, onRemove: () => void = () => {}, onZoom: (range: DateTimeObject) => void = () => {} ) { const hasFocusArea = useMemo( () => focusArea && focusArea.widgetIndex === widgetIndex, [focusArea, widgetIndex] ); const isDrawingRef = useRef(false); const theme = useTheme(); const startBrush = useCallback(() => { if (hasFocusArea || isDisabled) { return; } chartRef.current?.getEchartsInstance().dispatchAction({ type: 'takeGlobalCursor', key: 'brush', brushOption: { brushType: 'rect', }, }); isDrawingRef.current = true; }, [chartRef, hasFocusArea, isDisabled]); useEffect(() => { const handleMouseDown = event => { const rect = chartRef.current?.ele.getBoundingClientRect(); if (isInRect(event.clientX, event.clientY, rect)) { startBrush(); } }; window.addEventListener('mousedown', handleMouseDown, {capture: true}); return () => { window.removeEventListener('mousedown', handleMouseDown, {capture: true}); }; }, [chartRef, startBrush]); const onBrushEnd = useCallback( (brushEnd: BrushEndResult) => { if (isDisabled || !isDrawingRef.current) { return; } const rect = brushEnd.areas[0]; if (!rect) { return; } onAdd({ widgetIndex, range: getMetricRange(brushEnd, useFullYAxis), }); // Remove brush from echarts immediately after adding the focus area // since brushes get added to all charts in the group by default and then randomly // render in the wrong place chartRef.current?.getEchartsInstance().dispatchAction({ type: 'brush', brushType: 'clear', areas: [], }); isDrawingRef.current = false; }, [chartRef, isDisabled, onAdd, widgetIndex, useFullYAxis] ); const handleRemove = useCallback(() => { onRemove(); }, [onRemove]); const handleZoomIn = useCallback(() => { onZoom({ period: null, ...focusArea?.range, }); handleRemove(); }, [focusArea, handleRemove, onZoom]); const brushOptions = useMemo(() => { return { onBrushEnd, toolBox: { show: false, }, brush: { toolbox: ['rect'], xAxisIndex: 0, brushStyle: { borderWidth: 2, borderColor: theme.gray500, color: 'transparent', }, inBrush: { opacity: 1, }, outOfBrush: { opacity: 1, }, z: 10, } as EChartsOption['brush'], }; }, [onBrushEnd, theme.gray500]); if (hasFocusArea) { return { overlay: ( ), isDrawingRef, options: {}, }; } return { overlay: null, isDrawingRef, options: brushOptions, }; } type BrushRectOverlayProps = { chartRef: RefObject; onRemove: () => void; onZoom: () => void; rect: FocusArea | null; useFullYAxis: boolean; }; function BrushRectOverlay({ rect, onZoom, onRemove, useFullYAxis, chartRef, }: BrushRectOverlayProps) { const chartInstance = chartRef.current?.getEchartsInstance(); const [position, setPosition] = useState(null); const wrapperRef = useRef(null); useResizeObserver({ ref: wrapperRef, onResize: () => { chartInstance?.resize(); updatePosition(); }, }); const updatePosition = useCallback(() => { if (!rect || !chartInstance) { return; } const finder = {xAxisId: 'xAxis', yAxisId: 'yAxis'}; const topLeft = chartInstance.convertToPixel(finder, [ getTimestamp(rect.range.start), rect.range.max, ] as number[]); const bottomRight = chartInstance.convertToPixel(finder, [ getTimestamp(rect.range.end), rect.range.min, ] as number[]); if (!topLeft || !bottomRight) { return; } const widthPx = bottomRight[0] - topLeft[0]; const heightPx = bottomRight[1] - topLeft[1]; const resultTop = useFullYAxis ? '0px' : `${topLeft[1].toPrecision(5)}px`; const resultHeight = useFullYAxis ? `${CHART_HEIGHT}px` : `${heightPx.toPrecision(5)}px`; // Ensure the focus area rect is always within the chart bounds const left = Math.max(topLeft[0], 0); const width = Math.min(widthPx, chartInstance.getWidth() - left); setPosition({ left: `${left.toPrecision(5)}px`, top: resultTop, width: `${width.toPrecision(5)}px`, height: resultHeight, }); }, [rect, chartInstance, useFullYAxis]); useEffect(() => { updatePosition(); }, [rect, updatePosition]); if (!position) { return null; } const {left, top, width, height} = position; return (