Browse Source

feat(ddm): draw enhance area (#62334)

Ogi 1 year ago
parent
commit
113ab82685

+ 23 - 0
static/app/components/charts/baseChart.tsx

@@ -1,6 +1,7 @@
 import 'echarts/lib/component/grid';
 import 'echarts/lib/component/graphic';
 import 'echarts/lib/component/toolbox';
+import 'echarts/lib/component/brush';
 import 'zrender/lib/svg/svg';
 
 import {forwardRef, useMemo} from 'react';
@@ -28,6 +29,9 @@ import ReactEchartsCore from 'echarts-for-react/lib/core';
 import MarkLine from 'sentry/components/charts/components/markLine';
 import {space} from 'sentry/styles/space';
 import {
+  EChartBrushEndHandler,
+  EChartBrushSelectedHandler,
+  EChartBrushStartHandler,
   EChartChartReadyHandler,
   EChartClickHandler,
   EChartDataZoomHandler,
@@ -127,6 +131,10 @@ export interface BaseChartProps {
    * Axis pointer options
    */
   axisPointer?: AxisPointerComponentOption;
+  /**
+   * ECharts Brush options
+   */
+  brush?: EChartsOption['brush'];
   /**
    * Bucket size to display time range in chart tooltip
    */
@@ -188,6 +196,9 @@ export interface BaseChartProps {
    * states whether or not to merge with previous `option`
    */
   notMerge?: boolean;
+  onBrushEnd?: EChartBrushEndHandler;
+  onBrushSelected?: EChartBrushSelectedHandler;
+  onBrushStart?: EChartBrushStartHandler;
   onChartReady?: EChartChartReadyHandler;
   onClick?: EChartClickHandler;
   onDataZoom?: EChartDataZoomHandler;
@@ -305,6 +316,7 @@ const DEFAULT_Y_AXIS = {};
 const DEFAULT_X_AXIS = {};
 
 function BaseChartUnwrapped({
+  brush,
   colors,
   grid,
   tooltip,
@@ -339,6 +351,9 @@ function BaseChartUnwrapped({
   onRestore,
   onFinished,
   onRendered,
+  onBrushStart,
+  onBrushEnd,
+  onBrushSelected,
 
   options = DEFAULT_OPTIONS,
   series = DEFAULT_SERIES,
@@ -534,6 +549,7 @@ function BaseChartUnwrapped({
       dataZoom,
       graphic,
       aria,
+      brush,
     };
   }, [
     color,
@@ -549,6 +565,7 @@ function BaseChartUnwrapped({
     grid,
     legend,
     toolBox,
+    brush,
     axisPointer,
     dataZoom,
     graphic,
@@ -585,6 +602,9 @@ function BaseChartUnwrapped({
         rendered: (props, instance) => onRendered?.(props, instance),
         legendselectchanged: (props, instance) =>
           onLegendSelectChanged?.(props, instance),
+        brush: (props, instance) => onBrushStart?.(props, instance),
+        brushend: (props, instance) => onBrushEnd?.(props, instance),
+        brushselected: (props, instance) => onBrushSelected?.(props, instance),
       }) as ReactEchartProps['onEvents'],
     [
       onClick,
@@ -596,6 +616,9 @@ function BaseChartUnwrapped({
       onRestore,
       onFinished,
       onRendered,
+      onBrushStart,
+      onBrushEnd,
+      onBrushSelected,
     ]
   );
 

+ 22 - 0
static/app/types/echarts.tsx

@@ -116,3 +116,25 @@ export type EChartRestoreHandler = EChartEventHandler<{type: 'restore'}>;
 export type EChartFinishedHandler = EChartEventHandler<{}>;
 
 export type EChartRenderedHandler = EChartEventHandler<{}>;
+
+type EchartBrushAreas = {
+  coordRange: number[][];
+  range: number[][];
+}[];
+
+export type EChartBrushStartHandler = EChartEventHandler<{
+  areas: EchartBrushAreas;
+  brushId: string;
+  type: 'brush';
+}>;
+
+export type EChartBrushEndHandler = EChartEventHandler<{
+  areas: EchartBrushAreas;
+  brushId: string;
+  type: 'brushend';
+}>;
+
+export type EChartBrushSelectedHandler = EChartEventHandler<{
+  brushId: string;
+  type: 'brushselected';
+}>;

+ 81 - 44
static/app/views/ddm/chart.tsx

@@ -1,15 +1,22 @@
-import {useEffect, useMemo, useRef} from 'react';
+import {useCallback, useEffect, useMemo, useRef} from 'react';
 import styled from '@emotion/styled';
+import {useHover} from '@react-aria/interactions';
 
+import {updateDateTime} from 'sentry/actionCreators/pageFilters';
 import {AreaChart} from 'sentry/components/charts/areaChart';
 import {BarChart} from 'sentry/components/charts/barChart';
-import ChartZoom from 'sentry/components/charts/chartZoom';
 import {LineChart} from 'sentry/components/charts/lineChart';
-import {DateString} from 'sentry/types';
+import {DateTimeObject} from 'sentry/components/charts/utils';
 import {ReactEchartsRef} from 'sentry/types/echarts';
-import {formatMetricsUsingUnitAndOp, MetricDisplayType} from 'sentry/utils/metrics';
+import {
+  formatMetricsUsingUnitAndOp,
+  MetricDisplayType,
+  updateQuery,
+} from 'sentry/utils/metrics';
 import useRouter from 'sentry/utils/useRouter';
+import {useFocusAreaBrush} from 'sentry/views/ddm/chartBrush';
 import {DDM_CHART_GROUP} from 'sentry/views/ddm/constants';
+import {useDDMContext} from 'sentry/views/ddm/context';
 
 import {getFormatter} from '../../components/charts/components/tooltip';
 
@@ -18,26 +25,65 @@ import {Series} from './widget';
 type ChartProps = {
   displayType: MetricDisplayType;
   series: Series[];
+  widgetIndex: number;
   end?: string;
-  onZoom?: (start: DateString, end: DateString) => void;
   operation?: string;
   period?: string;
   start?: string;
   utc?: boolean;
 };
 
-export function MetricChart({
-  series,
-  displayType,
-  start,
-  end,
-  period,
-  utc,
-  operation,
-  onZoom,
-}: ChartProps) {
-  const chartRef = useRef<ReactEchartsRef>(null);
+export function MetricChart({series, displayType, operation, widgetIndex}: ChartProps) {
   const router = useRouter();
+  const chartRef = useRef<ReactEchartsRef>(null);
+
+  const {hoverProps, isHovered} = useHover({
+    isDisabled: false,
+  });
+
+  const {focusArea, addFocusArea, removeFocusArea} = useDDMContext();
+
+  const handleAddFocusArea = useCallback(
+    newFocusArea => {
+      addFocusArea(newFocusArea);
+      updateQuery(router, {focusArea: JSON.stringify(newFocusArea)});
+    },
+    [addFocusArea, router]
+  );
+
+  const handleRemoveFocusArea = useCallback(() => {
+    removeFocusArea();
+    updateQuery(router, {focusArea: null});
+  }, [removeFocusArea, router]);
+
+  const handleZoom = useCallback(
+    (range: DateTimeObject) => {
+      updateDateTime(range, router, {save: true});
+    },
+    [router]
+  );
+
+  const focusAreaBrush = useFocusAreaBrush(
+    chartRef,
+    focusArea,
+    handleAddFocusArea,
+    handleRemoveFocusArea,
+    handleZoom,
+    {
+      widgetIndex,
+      isDisabled: !isHovered,
+    }
+  );
+
+  useEffect(() => {
+    if (focusArea) {
+      return;
+    }
+    const urlFocusArea = router.location.query.focusArea;
+    if (urlFocusArea) {
+      addFocusArea(JSON.parse(urlFocusArea));
+    }
+  }, [router, addFocusArea, focusArea]);
 
   // TODO(ddm): Try to do this in a more elegant way
   useEffect(() => {
@@ -73,6 +119,8 @@ export function MetricChart({
       limit: 10,
     };
     return {
+      ...focusAreaBrush.options,
+      series: seriesToShow,
       forwardedRef: chartRef,
       isGroupedByDate: true,
       height: 300,
@@ -105,36 +153,25 @@ export function MetricChart({
         },
       },
     };
-  }, [bucketSize, isSubMinuteBucket, operation, seriesToShow, unit]);
+  }, [
+    bucketSize,
+    isSubMinuteBucket,
+    operation,
+    seriesToShow,
+    unit,
+    focusAreaBrush.options,
+  ]);
 
   return (
-    <ChartWrapper>
-      <ChartZoom
-        router={router}
-        period={period}
-        start={start}
-        end={end}
-        utc={utc}
-        onZoom={zoomPeriod => {
-          onZoom?.(zoomPeriod.start, zoomPeriod.end);
-        }}
-      >
-        {zoomRenderProps => {
-          const allProps = {
-            ...chartProps,
-            ...zoomRenderProps,
-            series: seriesToShow,
-          };
-
-          return displayType === MetricDisplayType.LINE ? (
-            <LineChart {...allProps} />
-          ) : displayType === MetricDisplayType.AREA ? (
-            <AreaChart {...allProps} />
-          ) : (
-            <BarChart stacked animation={false} {...allProps} />
-          );
-        }}
-      </ChartZoom>
+    <ChartWrapper {...hoverProps} onMouseDownCapture={focusAreaBrush.startBrush}>
+      {focusAreaBrush.overlay}
+      {displayType === MetricDisplayType.LINE ? (
+        <LineChart {...chartProps} />
+      ) : displayType === MetricDisplayType.AREA ? (
+        <AreaChart {...chartProps} />
+      ) : (
+        <BarChart stacked animation={false} {...chartProps} />
+      )}
       {displayFogOfWar && (
         <FogOfWar bucketSize={bucketSize} seriesLength={seriesLength} />
       )}

+ 217 - 0
static/app/views/ddm/chartBrush.tsx

@@ -0,0 +1,217 @@
+import {RefObject, useCallback, useMemo} from 'react';
+import styled from '@emotion/styled';
+import {EChartsOption} from 'echarts';
+import moment from 'moment';
+
+import {Button} from 'sentry/components/button';
+import {IconDelete, IconStack, IconZoom} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {EChartBrushEndHandler, ReactEchartsRef} from 'sentry/types/echarts';
+import {getUtcToLocalDateObject} from 'sentry/utils/dates';
+import theme from 'sentry/utils/theme';
+
+import {DateTimeObject} from '../../components/charts/utils';
+
+interface AbsolutePosition {
+  height: string;
+  left: string;
+  top: string;
+  width: string;
+}
+
+export interface FocusArea {
+  datapoints: {
+    x: number[];
+    y: number[];
+  };
+  position: AbsolutePosition;
+  widgetIndex: number;
+}
+
+interface UseFocusAreaBrushOptions {
+  widgetIndex: number;
+  isDisabled?: boolean;
+}
+
+type BrushEndResult = Parameters<EChartBrushEndHandler>[0];
+
+export function useFocusAreaBrush(
+  chartRef: RefObject<ReactEchartsRef>,
+  focusArea: FocusArea | null,
+  onAdd: (area: FocusArea) => void,
+  onRemove: () => void,
+  onZoom: (range: DateTimeObject) => void,
+  {widgetIndex, isDisabled = false}: UseFocusAreaBrushOptions
+) {
+  const onBrushEnd = useCallback(
+    (brushEnd: BrushEndResult) => {
+      if (isDisabled) {
+        return;
+      }
+
+      const rect = brushEnd.areas[0];
+      if (!rect) {
+        return;
+      }
+
+      const chartWidth = chartRef.current?.getEchartsInstance().getWidth() ?? 100;
+
+      const position = getPosition(brushEnd, chartWidth);
+
+      onAdd({
+        widgetIndex,
+        position,
+        datapoints: {
+          x: rect.coordRange[0],
+          y: rect.coordRange[1],
+        },
+      });
+    },
+    [chartRef, isDisabled, onAdd, widgetIndex]
+  );
+
+  const startBrush = useCallback(() => {
+    chartRef.current?.getEchartsInstance().dispatchAction({
+      type: 'takeGlobalCursor',
+      key: 'brush',
+      brushOption: {
+        brushType: 'rect',
+      },
+    });
+  }, [chartRef]);
+
+  const handleZoomIn = useCallback(() => {
+    const startFormatted = getDate(focusArea?.datapoints.x[0]);
+    const endFormatted = getDate(focusArea?.datapoints.x[1]);
+    onZoom({
+      period: null,
+      start: startFormatted ? getUtcToLocalDateObject(startFormatted) : startFormatted,
+      end: endFormatted ? getUtcToLocalDateObject(endFormatted) : endFormatted,
+    });
+
+    onRemove();
+  }, [focusArea, onRemove, onZoom]);
+
+  const renderOverlay = focusArea && focusArea.widgetIndex === widgetIndex;
+
+  const brushOptions = useMemo(() => {
+    return {
+      onBrushEnd,
+      toolBox: {
+        show: false,
+      },
+      brush: {
+        toolbox: ['rect'],
+        xAxisIndex: 0,
+        brushStyle: {
+          borderWidth: 2,
+          borderColor: theme.purple300,
+          color: 'transparent',
+        },
+        z: 10,
+      } as EChartsOption['brush'],
+    };
+  }, [onBrushEnd]);
+
+  if (renderOverlay) {
+    return {
+      overlay: (
+        <BrushRectOverlay rect={focusArea} onRemove={onRemove} onZoom={handleZoomIn} />
+      ),
+      startBrush,
+      options: {},
+    };
+  }
+
+  return {
+    overlay: null,
+    startBrush,
+    options: brushOptions,
+  };
+}
+
+function BrushRectOverlay({rect, onZoom, onRemove}) {
+  if (!rect) {
+    return null;
+  }
+
+  const {top, left, width, height} = rect.position;
+
+  return (
+    <FocusAreaRect top={top} left={left} width={width} height={height}>
+      <FocusAreaRectActions top={height}>
+        <Button size="xs" disabled icon={<IconStack />}>
+          {t('Show samples')}
+        </Button>
+        <Button
+          size="xs"
+          onClick={onZoom}
+          icon={<IconZoom isZoomIn />}
+          aria-label="zoom"
+        />
+        <Button size="xs" onClick={onRemove} icon={<IconDelete />} aria-label="remove" />
+      </FocusAreaRectActions>
+    </FocusAreaRect>
+  );
+}
+
+const getDate = date =>
+  date ? moment.utc(date).format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS) : null;
+
+const getPosition = (params: BrushEndResult, chartWidth: number): AbsolutePosition => {
+  const rect = params.areas[0];
+  if (!rect) {
+    return {
+      left: '0',
+      top: '0',
+      width: '0',
+      height: '0',
+    };
+  }
+
+  const left = rect.range[0][0];
+  const width = rect.range[0][1] - left;
+
+  const leftPercentage = (left / chartWidth) * 100;
+  const widthPercentage = (width / chartWidth) * 100;
+
+  const topPx = Math.min(...rect.range[1]);
+  const heightPx = Math.max(...rect.range[1]) - topPx;
+
+  return {
+    left: `${leftPercentage.toPrecision(3)}%`,
+    top: `${topPx}px`,
+    width: `${widthPercentage.toPrecision(3)}%`,
+    height: `${heightPx}px`,
+  };
+};
+
+const FocusAreaRectActions = styled('div')<{
+  top: string;
+}>`
+  position: absolute;
+  top: ${p => p.top};
+  display: flex;
+  gap: ${space(0.5)};
+  padding: ${space(1)};
+  z-index: 2;
+  pointer-events: auto;
+`;
+
+const FocusAreaRect = styled('div')<{
+  height: string;
+  left: string;
+  top: string;
+  width: string;
+}>`
+  position: absolute;
+  top: ${p => p.top};
+  left: ${p => p.left};
+  width: ${p => p.width};
+  height: ${p => p.height};
+  outline: 2px solid ${p => p.theme.purple300};
+  outline-offset: -1px;
+  padding: ${space(1)};
+  pointer-events: none;
+`;

+ 24 - 0
static/app/views/ddm/context.tsx

@@ -12,14 +12,18 @@ import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
 import {decodeList} from 'sentry/utils/queryString';
 import usePageFilters from 'sentry/utils/usePageFilters';
 import useRouter from 'sentry/utils/useRouter';
+import {FocusArea} from 'sentry/views/ddm/chartBrush';
 import {DEFAULT_SORT_STATE} from 'sentry/views/ddm/constants';
 
 interface DDMContextValue {
+  addFocusArea: (area: FocusArea) => void;
   addWidget: () => void;
   duplicateWidget: (index: number) => void;
+  focusArea: FocusArea | null;
   hasCustomMetrics: boolean;
   isLoading: boolean;
   metricsMeta: ReturnType<typeof useMetricsMeta>['data'];
+  removeFocusArea: () => void;
   removeWidget: (index: number) => void;
   selectedWidgetIndex: number;
   setSelectedWidgetIndex: (index: number) => void;
@@ -33,11 +37,14 @@ export const DDMContext = createContext<DDMContextValue>({
   addWidget: () => {},
   updateWidget: () => {},
   removeWidget: () => {},
+  addFocusArea: () => {},
+  removeFocusArea: () => {},
   duplicateWidget: () => {},
   widgets: [],
   metricsMeta: [],
   hasCustomMetrics: false,
   isLoading: false,
+  focusArea: null,
 });
 
 export function useDDMContext() {
@@ -135,6 +142,8 @@ export function useMetricWidgets() {
 
 export function DDMContextProvider({children}: {children: React.ReactNode}) {
   const [selectedWidgetIndex, setSelectedWidgetIndex] = useState(0);
+  const [focusArea, setFocusArea] = useState<FocusArea | null>(null);
+
   const {widgets, updateWidget, addWidget, removeWidget, duplicateWidget} =
     useMetricWidgets();
 
@@ -173,6 +182,15 @@ export function DDMContextProvider({children}: {children: React.ReactNode}) {
     [duplicateWidget]
   );
 
+  const handleAddFocusArea = useCallback((area: FocusArea) => {
+    setFocusArea(area);
+    setSelectedWidgetIndex(area.widgetIndex);
+  }, []);
+
+  const handleRemoveFocusArea = useCallback(() => {
+    setFocusArea(null);
+  }, []);
+
   const contextValue = useMemo<DDMContextValue>(
     () => ({
       addWidget: handleAddWidget,
@@ -186,6 +204,9 @@ export function DDMContextProvider({children}: {children: React.ReactNode}) {
       hasCustomMetrics,
       isLoading,
       metricsMeta,
+      focusArea,
+      addFocusArea: handleAddFocusArea,
+      removeFocusArea: handleRemoveFocusArea,
     }),
     [
       handleAddWidget,
@@ -197,6 +218,9 @@ export function DDMContextProvider({children}: {children: React.ReactNode}) {
       metricsMeta,
       selectedWidgetIndex,
       widgets,
+      focusArea,
+      handleAddFocusArea,
+      handleRemoveFocusArea,
     ]
   );
 

+ 5 - 2
static/app/views/ddm/widget.tsx

@@ -111,6 +111,7 @@ export const MetricWidget = memo(
           </MetricWidgetHeader>
           {widget.mri ? (
             <MetricWidgetBody
+              widgetIndex={index}
               datetime={datetime}
               projects={projects}
               environments={environments}
@@ -140,6 +141,7 @@ const MetricWidgetHeader = styled('div')`
 
 interface MetricWidgetProps extends MetricWidgetQueryParams {
   onChange: (data: Partial<MetricWidgetQueryParams>) => void;
+  widgetIndex: number;
 }
 
 const MetricWidgetBody = memo(
@@ -148,11 +150,12 @@ const MetricWidgetBody = memo(
     displayType,
     focusedSeries,
     sort,
+    widgetIndex,
     ...metricsQuery
   }: MetricWidgetProps & PageFilters) => {
     const {mri, op, query, groupBy, projects, environments, datetime} = metricsQuery;
 
-    const {data, isLoading, isError, error, onZoom} = useMetricsDataZoom(
+    const {data, isLoading, isError, error} = useMetricsDataZoom(
       {
         mri,
         op,
@@ -223,7 +226,7 @@ const MetricWidgetBody = memo(
           displayType={displayType}
           operation={metricsQuery.op}
           {...normalizeChartTimeParams(data)}
-          onZoom={onZoom}
+          widgetIndex={widgetIndex}
         />
         {metricsQuery.showSummaryTable && (
           <SummaryTable