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 isEqual from 'lodash/isEqual'; import moment from 'moment'; import {Button} from 'sentry/components/button'; import type {DateTimeObject} from 'sentry/components/charts/utils'; import {IconClose, IconZoom} from 'sentry/icons'; import {space} from 'sentry/styles/space'; import type {DateString} from 'sentry/types'; import type {EChartBrushEndHandler, ReactEchartsRef} from 'sentry/types/echarts'; import mergeRefs from 'sentry/utils/mergeRefs'; import type {ValueRect} from 'sentry/views/ddm/chart/chartUtils'; import {getValueRect} from 'sentry/views/ddm/chart/chartUtils'; import type { CombinedMetricChartProps, FocusAreaSelection, SelectionRange, } from 'sentry/views/ddm/chart/types'; import { SAMPLES_X_AXIS_ID, SAMPLES_Y_AXIS_ID, } from 'sentry/views/ddm/chart/useMetricChartSamples'; import {CHART_HEIGHT} from 'sentry/views/ddm/constants'; import type {FocusAreaProps} from 'sentry/views/ddm/context'; interface AbsolutePosition { height: string; left: string; top: string; width: string; } interface UseFocusAreaOptions { widgetIndex: number; isDisabled?: boolean; useFullYAxis?: boolean; } export interface UseFocusAreaProps extends FocusAreaProps { chartRef: RefObject; opts: UseFocusAreaOptions; scalingFactor: number; chartUnit?: string; onZoom?: (range: DateTimeObject) => void; sampleUnit?: string; } type BrushEndResult = Parameters[0]; export function useFocusArea({ selection: selection, opts: {widgetIndex, isDisabled, useFullYAxis}, onAdd, onDraw, onRemove, onZoom, }: UseFocusAreaProps) { const hasFocusArea = selection && selection.widgetIndex === widgetIndex; const chartRef = useRef(null); const chartElement = chartRef.current?.ele; 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 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); }; }, [chartElement, 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; }, [isDisabled, useFullYAxis, onAdd, widgetIndex] ); const handleRemove = useCallback(() => { onRemove?.(); }, [onRemove]); const handleZoomIn = useCallback(() => { handleRemove(); onZoom?.({ period: null, ...selection?.range, }); }, [selection, handleRemove, onZoom]); const applyChartProps = useCallback( (baseProps: CombinedMetricChartProps): CombinedMetricChartProps => { return { ...baseProps, forwardedRef: mergeRefs([baseProps.forwardedRef, chartRef]), tooltip: { formatter: (params, asyncTicket) => { // Deactivate tooltips while drawing if (isDrawingRef.current) { return ''; } const baseFormatter = baseProps.tooltip?.formatter; if (typeof baseFormatter === 'string') { return baseFormatter; } if (!baseFormatter) { throw new Error( 'You need to define a tooltip formatter for the chart when using the focus area' ); } return baseFormatter(params, asyncTicket); }, }, onBrushEnd, toolBox: { show: false, }, brush: { toolbox: ['rect'], xAxisIndex: Array.isArray(baseProps.xAxes) ? baseProps.xAxes.findIndex(a => a?.id === SAMPLES_X_AXIS_ID) : 0, yAxisIndex: Array.isArray(baseProps.yAxes) ? baseProps.yAxes.findIndex(a => a?.id === SAMPLES_Y_AXIS_ID) : 0, brushStyle: { borderWidth: 2, borderColor: theme.gray500, color: 'transparent', }, inBrush: { opacity: 1, }, outOfBrush: { opacity: 1, }, z: 10, }, }; }, [onBrushEnd, theme.gray500] ); return useMemo( () => ({ applyChartProps, overlay: hasFocusArea ? ( ) : null, }), [applyChartProps, handleRemove, handleZoomIn, hasFocusArea, selection, useFullYAxis] ); } export type UseFocusAreaResult = ReturnType; type FocusAreaOverlayProps = { chartRef: RefObject; onRemove: () => void; onZoom: () => void; rect: FocusAreaSelection | null; useFullYAxis: boolean; }; function FocusAreaOverlay({ rect, onZoom, onRemove, useFullYAxis, chartRef, }: FocusAreaOverlayProps) { 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 || rect.range.max === undefined || rect.range.min === undefined || rect.range.start === undefined || rect.range.end === undefined ) { return; } const finder = {xAxisId: SAMPLES_X_AXIS_ID, yAxisId: SAMPLES_Y_AXIS_ID}; const max = rect.range.max; const min = rect.range.min; const topLeft = chartInstance.convertToPixel(finder, [ getTimestamp(rect.range.start), max, ]); const bottomRight = chartInstance.convertToPixel(finder, [ getTimestamp(rect.range.end), 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(() => { // In some cases echarts is not yet done with updating the chart // and the sample axes are not yet available to read the position from // so we need to delay the update until the next microtask queueMicrotask(updatePosition); }, [rect, updatePosition]); if (!position) { return null; } const {left, top, width, height} = position; return (