import type {RefObject} from 'react'; import {Fragment, 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 type {EChartsOption} from 'echarts'; import isEqual from 'lodash/isEqual'; import moment from 'moment'; import {Button} from 'sentry/components/button'; import {IconClose, IconZoom} from 'sentry/icons'; import {space} from 'sentry/styles/space'; import type {EChartBrushEndHandler, ReactEchartsRef} from 'sentry/types/echarts'; import type {SelectionRange} from 'sentry/utils/metrics/types'; import type {ValueRect} from 'sentry/views/ddm/chartUtils'; import {getValueRect} from 'sentry/views/ddm/chartUtils'; import {CHART_HEIGHT} from 'sentry/views/ddm/constants'; import type {FocusAreaProps} from 'sentry/views/ddm/context'; import type {DateTimeObject} from '../../components/charts/utils'; interface AbsolutePosition { height: string; left: string; top: string; width: string; } interface UseFocusAreaOptions { widgetIndex: number; isDisabled?: boolean; useFullYAxis?: boolean; } export interface FocusAreaSelection { range: SelectionRange; widgetIndex: number; } export interface UseFocusAreaProps extends FocusAreaProps { chartRef: RefObject; opts: UseFocusAreaOptions; onZoom?: (range: DateTimeObject) => void; } type BrushEndResult = Parameters[0]; export function useFocusArea({ chartRef, selection: selection, opts: {widgetIndex, isDisabled, useFullYAxis}, onAdd, onDraw, onRemove, onZoom, }: UseFocusAreaProps) { const hasFocusArea = useMemo( () => selection && selection.widgetIndex === widgetIndex, [selection, widgetIndex] ); const isDrawingRef = useRef(false); const theme = useTheme(); const startBrush = useCallback(() => { if (hasFocusArea || isDisabled) { return; } onDraw?.(); chartRef.current?.getEchartsInstance().dispatchAction({ type: 'takeGlobalCursor', key: 'brush', brushOption: { brushType: 'rect', }, }); }, [chartRef, hasFocusArea, isDisabled, onDraw]); useEffect(() => { const chartElement = chartRef.current?.ele; const handleMouseDown = () => { isDrawingRef.current = true; startBrush(); }; // Handle mouse up is called after onBrushEnd // We can use it for a final reliable cleanup as onBrushEnd is not always called (e.g. when simply clicking the chart) const handleMouseUp = () => { isDrawingRef.current = false; }; chartElement?.addEventListener('mousedown', handleMouseDown, {capture: true}); window.addEventListener('mouseup', handleMouseUp); return () => { chartElement?.removeEventListener('mousedown', handleMouseDown, {capture: true}); window.removeEventListener('mouseup', handleMouseUp); }; }, [chartRef, startBrush]); const onBrushEnd = useCallback( (brushEnd: BrushEndResult) => { if (isDisabled || !isDrawingRef.current) { return; } const rect = brushEnd.areas[0]; if (!rect) { return; } const range = getSelectionRange(brushEnd, !!useFullYAxis, getValueRect(chartRef)); onAdd?.({ widgetIndex, range, }); // 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(() => { handleRemove(); onZoom?.({ period: null, ...selection?.range, }); }, [selection, 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: FocusAreaSelection | null; useFullYAxis: boolean; }; function BrushRectOverlay({ rect, onZoom, onRemove, useFullYAxis, chartRef, }: BrushRectOverlayProps) { const [position, setPosition] = useState(null); const wrapperRef = useRef(null); useResizeObserver({ ref: wrapperRef, onResize: () => { const chartInstance = chartRef.current?.getEchartsInstance(); chartInstance?.resize(); updatePosition(); }, }); const updatePosition = useCallback(() => { const chartInstance = chartRef.current?.getEchartsInstance(); 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); const newPosition = { left: `${left.toPrecision(5)}px`, top: resultTop, width: `${width.toPrecision(5)}px`, height: resultHeight, }; if (!isEqual(newPosition, position)) { setPosition(newPosition); } }, [chartRef, rect, useFullYAxis, position]); useEffect(() => { updatePosition(); }, [rect, updatePosition]); if (!position) { return null; } const {left, top, width, height} = position; return (