Browse Source

feat(ddm): Fast charts (#62378)

Use canvas renderer for charts with more than 20 series in ddm.
Use API of chart instance to highlight series when hovering the summary
table.
ArthurKnaus 1 year ago
parent
commit
f0fbe0dcd5

+ 3 - 0
static/app/components/charts/areaChart.tsx

@@ -35,6 +35,9 @@ export function AreaChart({series, stacked, colors, ...props}: AreaChartProps) {
             color: colors?.[i],
             opacity: 1.0,
           },
+          // Define the z level so that the series remain stacked in the correct order
+          // even after operations like hiding / highlighting series
+          z: i,
           animation: false,
           animationThreshold: 1,
           animationDuration: 0,

+ 148 - 140
static/app/views/ddm/chart.tsx

@@ -1,6 +1,8 @@
-import {useCallback, useEffect, useMemo, useRef} from 'react';
+import {forwardRef, useCallback, useEffect, useMemo, useRef} from 'react';
 import styled from '@emotion/styled';
 import {useHover} from '@react-aria/interactions';
+import * as echarts from 'echarts/core';
+import {CanvasRenderer} from 'echarts/renderers';
 
 import {updateDateTime} from 'sentry/actionCreators/pageFilters';
 import {AreaChart} from 'sentry/components/charts/areaChart';
@@ -8,6 +10,7 @@ import {BarChart} from 'sentry/components/charts/barChart';
 import {LineChart} from 'sentry/components/charts/lineChart';
 import {DateTimeObject} from 'sentry/components/charts/utils';
 import {ReactEchartsRef} from 'sentry/types/echarts';
+import mergeRefs from 'sentry/utils/mergeRefs';
 import {
   formatMetricsUsingUnitAndOp,
   MetricDisplayType,
@@ -26,158 +29,163 @@ type ChartProps = {
   displayType: MetricDisplayType;
   series: Series[];
   widgetIndex: number;
-  end?: string;
   operation?: string;
-  period?: string;
-  start?: string;
-  utc?: boolean;
 };
 
-export function MetricChart({series, displayType, operation, widgetIndex}: ChartProps) {
-  const router = useRouter();
-  const chartRef = useRef<ReactEchartsRef>(null);
+// We need to enable canvas renderer for echarts before we use it here.
+// Once we use it in more places, this should probably move to a more global place
+// But for now we keep it here to not invluence the bundle size of the main chunks.
+echarts.use(CanvasRenderer);
 
-  const {hoverProps, isHovered} = useHover({
-    isDisabled: false,
-  });
+export const MetricChart = forwardRef<ReactEchartsRef, ChartProps>(
+  ({series, displayType, operation, widgetIndex}, forwardedRef) => {
+    const router = useRouter();
+    const chartRef = useRef<ReactEchartsRef>(null);
 
-  const {focusArea, addFocusArea, removeFocusArea} = useDDMContext();
+    const {hoverProps, isHovered} = useHover({
+      isDisabled: false,
+    });
 
-  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 {focusArea, addFocusArea, removeFocusArea} = useDDMContext();
 
-  const handleZoom = useCallback(
-    (range: DateTimeObject) => {
-      updateDateTime(range, router, {save: true});
-    },
-    [router]
-  );
-
-  const focusAreaBrush = useFocusAreaBrush(
-    chartRef,
-    focusArea,
-    handleAddFocusArea,
-    handleRemoveFocusArea,
-    handleZoom,
-    {
-      widgetIndex,
-      isDisabled: !isHovered,
-    }
-  );
+    const handleAddFocusArea = useCallback(
+      newFocusArea => {
+        addFocusArea(newFocusArea);
+        updateQuery(router, {focusArea: JSON.stringify(newFocusArea)});
+      },
+      [addFocusArea, router]
+    );
 
-  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(() => {
-    const echartsInstance = chartRef?.current?.getEchartsInstance();
-    if (echartsInstance && !echartsInstance.group) {
-      echartsInstance.group = DDM_CHART_GROUP;
-    }
-  });
-
-  const unit = series[0]?.unit;
-  const seriesToShow = useMemo(
-    () =>
-      series
-        .filter(s => !s.hidden)
-        .map(s => ({...s, silent: displayType === MetricDisplayType.BAR})),
-    [series, displayType]
-  );
+    const handleRemoveFocusArea = useCallback(() => {
+      removeFocusArea();
+      updateQuery(router, {focusArea: null});
+    }, [removeFocusArea, router]);
 
-  // TODO(ddm): This assumes that all series have the same bucket size
-  const bucketSize = seriesToShow[0]?.data[1]?.name - seriesToShow[0]?.data[0]?.name;
-  const isSubMinuteBucket = bucketSize < 60_000;
-  const seriesLength = seriesToShow[0]?.data.length;
-  const displayFogOfWar = operation && ['sum', 'count'].includes(operation);
-
-  const chartProps = useMemo(() => {
-    const formatters = {
-      valueFormatter: (value: number) =>
-        formatMetricsUsingUnitAndOp(value, unit, operation),
-      isGroupedByDate: true,
-      bucketSize,
-      showTimeInTooltip: true,
-      addSecondsToTimeFormat: isSubMinuteBucket,
-      limit: 10,
-    };
-    return {
-      ...focusAreaBrush.options,
-      series: seriesToShow,
-      forwardedRef: chartRef,
-      isGroupedByDate: true,
-      height: 300,
-      colors: seriesToShow.map(s => s.color),
-      grid: {top: 20, bottom: 20, left: 15, right: 25},
-      tooltip: {
-        formatter: (params, asyncTicket) => {
-          const hoveredEchartElement = Array.from(
-            document.querySelectorAll(':hover')
-          ).find(element => {
-            return element.classList.contains('echarts-for-react');
-          });
-
-          if (hoveredEchartElement === chartRef?.current?.ele) {
-            return getFormatter(formatters)(params, asyncTicket);
-          }
-          return '';
-        },
+    const handleZoom = useCallback(
+      (range: DateTimeObject) => {
+        updateDateTime(range, router, {save: true});
       },
-      yAxis: {
-        axisLabel: {
-          formatter: (value: number) => {
-            return formatMetricsUsingUnitAndOp(value, unit, operation);
+      [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(() => {
+      const echartsInstance = chartRef?.current?.getEchartsInstance();
+      if (echartsInstance && !echartsInstance.group) {
+        echartsInstance.group = DDM_CHART_GROUP;
+      }
+    });
+
+    const unit = series[0]?.unit;
+    const seriesToShow = useMemo(
+      () =>
+        series
+          .filter(s => !s.hidden)
+          .map(s => ({...s, silent: displayType === MetricDisplayType.BAR})),
+      [series, displayType]
+    );
+
+    // TODO(ddm): This assumes that all series have the same bucket size
+    const bucketSize = seriesToShow[0]?.data[1]?.name - seriesToShow[0]?.data[0]?.name;
+    const isSubMinuteBucket = bucketSize < 60_000;
+    const seriesLength = seriesToShow[0]?.data.length;
+    const displayFogOfWar = operation && ['sum', 'count'].includes(operation);
+
+    const chartProps = useMemo(() => {
+      const formatters = {
+        valueFormatter: (value: number) =>
+          formatMetricsUsingUnitAndOp(value, unit, operation),
+        isGroupedByDate: true,
+        bucketSize,
+        showTimeInTooltip: true,
+        addSecondsToTimeFormat: isSubMinuteBucket,
+        limit: 10,
+      };
+      return {
+        ...focusAreaBrush.options,
+        forwardedRef: mergeRefs([forwardedRef, chartRef]),
+        series: seriesToShow,
+        renderer: seriesToShow.length > 20 ? ('canvas' as const) : ('svg' as const),
+        isGroupedByDate: true,
+        height: 300,
+        colors: seriesToShow.map(s => s.color),
+        grid: {top: 20, bottom: 20, left: 15, right: 25},
+        tooltip: {
+          formatter: (params, asyncTicket) => {
+            const hoveredEchartElement = Array.from(
+              document.querySelectorAll(':hover')
+            ).find(element => {
+              return element.classList.contains('echarts-for-react');
+            });
+
+            if (hoveredEchartElement === chartRef?.current?.ele) {
+              return getFormatter(formatters)(params, asyncTicket);
+            }
+            return '';
           },
         },
-      },
-      xAxis: {
-        axisPointer: {
-          snap: true,
+        yAxis: {
+          axisLabel: {
+            formatter: (value: number) => {
+              return formatMetricsUsingUnitAndOp(value, unit, operation);
+            },
+          },
         },
-      },
-    };
-  }, [
-    bucketSize,
-    isSubMinuteBucket,
-    operation,
-    seriesToShow,
-    unit,
-    focusAreaBrush.options,
-  ]);
-
-  return (
-    <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} />
-      )}
-    </ChartWrapper>
-  );
-}
+        xAxis: {
+          axisPointer: {
+            snap: true,
+          },
+        },
+      };
+    }, [
+      bucketSize,
+      focusAreaBrush.options,
+      forwardedRef,
+      isSubMinuteBucket,
+      operation,
+      seriesToShow,
+      unit,
+    ]);
+
+    return (
+      <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} />
+        )}
+      </ChartWrapper>
+    );
+  }
+);
 
 function FogOfWar({
   bucketSize,

+ 80 - 74
static/app/views/ddm/summaryTable.tsx

@@ -24,13 +24,13 @@ export function SummaryTable({
   onRowClick,
   onSortChange,
   sort = DEFAULT_SORT_STATE as SortState,
-  setHoveredLegend,
+  setHoveredSeries,
 }: {
   onRowClick: (seriesName: string) => void;
   onSortChange: (sortState: SortState) => void;
   series: Series[];
-  setHoveredLegend: React.Dispatch<React.SetStateAction<string>> | undefined;
   operation?: string;
+  setHoveredSeries?: (seriesName: string) => void;
   sort?: SortState;
 }) {
   const {selection} = usePageFilters();
@@ -155,79 +155,81 @@ export function SummaryTable({
           {t('Actions')}
         </HeaderCell>
       )}
+      <TableBodyWrapper
+        onMouseLeave={() => {
+          if (hasMultipleSeries) {
+            setHoveredSeries?.('');
+          }
+        }}
+      >
+        {rows.map(
+          ({
+            name,
+            seriesName,
+            color,
+            hidden,
+            unit,
+            transaction,
+            release,
+            avg,
+            min,
+            max,
+            sum,
+          }) => {
+            return (
+              <Fragment key={seriesName}>
+                <CellWrapper
+                  onClick={() => {
+                    if (hasMultipleSeries) {
+                      onRowClick(seriesName);
+                    }
+                  }}
+                  onMouseEnter={() => {
+                    if (hasMultipleSeries) {
+                      setHoveredSeries?.(seriesName);
+                    }
+                  }}
+                >
+                  <Cell>
+                    <ColorDot color={color} isHidden={!!hidden} />
+                  </Cell>
+                  <TextOverflowCell>{name}</TextOverflowCell>
+                  {/* TODO(ddm): Add a tooltip with the full value, don't add on click in case users want to copy the value */}
+                  <Cell right>{formatMetricsUsingUnitAndOp(avg, unit, operation)}</Cell>
+                  <Cell right>{formatMetricsUsingUnitAndOp(min, unit, operation)}</Cell>
+                  <Cell right>{formatMetricsUsingUnitAndOp(max, unit, operation)}</Cell>
+                  <Cell right>{formatMetricsUsingUnitAndOp(sum, unit, operation)}</Cell>
+                </CellWrapper>
+                {hasActions && (
+                  <Cell right>
+                    <ButtonBar gap={0.5}>
+                      {transaction && (
+                        <div>
+                          <Tooltip title={t('Open Transaction Summary')}>
+                            <LinkButton to={transactionTo(transaction)} size="xs">
+                              <IconLightning size="xs" />
+                            </LinkButton>
+                          </Tooltip>
+                        </div>
+                      )}
 
-      {rows.map(
-        ({
-          name,
-          seriesName,
-          color,
-          hidden,
-          unit,
-          transaction,
-          release,
-          avg,
-          min,
-          max,
-          sum,
-        }) => {
-          return (
-            <Fragment key={seriesName}>
-              <CellWrapper
-                onClick={() => {
-                  if (hasMultipleSeries) {
-                    onRowClick(seriesName);
-                  }
-                }}
-                onMouseEnter={() => {
-                  if (hasMultipleSeries) {
-                    setHoveredLegend?.(seriesName);
-                  }
-                }}
-                onMouseLeave={() => {
-                  if (hasMultipleSeries) {
-                    setHoveredLegend?.('');
-                  }
-                }}
-              >
-                <Cell>
-                  <ColorDot color={color} isHidden={!!hidden} />
-                </Cell>
-                <TextOverflowCell>{name}</TextOverflowCell>
-                {/* TODO(ddm): Add a tooltip with the full value, don't add on click in case users want to copy the value */}
-                <Cell right>{formatMetricsUsingUnitAndOp(avg, unit, operation)}</Cell>
-                <Cell right>{formatMetricsUsingUnitAndOp(min, unit, operation)}</Cell>
-                <Cell right>{formatMetricsUsingUnitAndOp(max, unit, operation)}</Cell>
-                <Cell right>{formatMetricsUsingUnitAndOp(sum, unit, operation)}</Cell>
-              </CellWrapper>
-              {hasActions && (
-                <Cell right>
-                  <ButtonBar gap={0.5}>
-                    {transaction && (
-                      <div>
-                        <Tooltip title={t('Open Transaction Summary')}>
-                          <LinkButton to={transactionTo(transaction)} size="xs">
-                            <IconLightning size="xs" />
-                          </LinkButton>
-                        </Tooltip>
-                      </div>
-                    )}
-
-                    {release && (
-                      <div>
-                        <Tooltip title={t('Open Release Details')}>
-                          <LinkButton to={releaseTo(release)} size="xs">
-                            <IconReleases size="xs" />
-                          </LinkButton>
-                        </Tooltip>
-                      </div>
-                    )}
-                  </ButtonBar>
-                </Cell>
-              )}
-            </Fragment>
-          );
-        }
-      )}
+                      {release && (
+                        <div>
+                          <Tooltip title={t('Open Release Details')}>
+                            <LinkButton to={releaseTo(release)} size="xs">
+                              <IconReleases size="xs" />
+                            </LinkButton>
+                          </Tooltip>
+                        </div>
+                      )}
+                    </ButtonBar>
+                  </Cell>
+                )}
+              </Fragment>
+            );
+          }
+        )}
+      </TableBodyWrapper>
     </SummaryTableWrapper>
   );
 }
@@ -341,6 +343,10 @@ const ColorDot = styled(`div`)<{color: string; isHidden: boolean}>`
   height: ${space(1)};
 `;
 
+const TableBodyWrapper = styled('div')`
+  display: contents;
+`;
+
 const CellWrapper = styled('div')`
   display: contents;
   &:hover {

+ 19 - 7
static/app/views/ddm/widget.tsx

@@ -1,4 +1,4 @@
-import {memo, useCallback, useEffect, useMemo, useState} from 'react';
+import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react';
 import styled from '@emotion/styled';
 import colorFn from 'color';
 import type {LineSeriesOption} from 'echarts';
@@ -15,6 +15,7 @@ import {IconSearch} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {MetricsApiResponse, MRI, PageFilters} from 'sentry/types';
+import {ReactEchartsRef} from 'sentry/types/echarts';
 import {
   getSeriesName,
   MetricDisplayType,
@@ -168,16 +169,27 @@ const MetricWidgetBody = memo(
       {fidelity: displayType === MetricDisplayType.BAR ? 'low' : 'high'}
     );
 
-    const [hoveredLegend, setHoveredLegend] = useState('');
+    const chartRef = useRef<ReactEchartsRef>(null);
+
+    const setHoveredSeries = useCallback((legend: string) => {
+      if (!chartRef.current) {
+        return;
+      }
+      const echartsInstance = chartRef.current.getEchartsInstance();
+      echartsInstance.dispatchAction({
+        type: 'highlight',
+        seriesName: legend,
+      });
+    }, []);
 
     const toggleSeriesVisibility = useCallback(
       (seriesName: string) => {
-        setHoveredLegend('');
+        setHoveredSeries('');
         onChange({
           focusedSeries: focusedSeries === seriesName ? undefined : seriesName,
         });
       },
-      [focusedSeries, onChange]
+      [focusedSeries, onChange, setHoveredSeries]
     );
 
     const chartSeries = useMemo(
@@ -186,11 +198,10 @@ const MetricWidgetBody = memo(
         getChartSeries(data, {
           mri,
           focusedSeries,
-          hoveredLegend,
           groupBy: metricsQuery.groupBy,
           displayType,
         }),
-      [data, displayType, focusedSeries, hoveredLegend, metricsQuery.groupBy, mri]
+      [data, displayType, focusedSeries, metricsQuery.groupBy, mri]
     );
 
     if (!chartSeries || !data || isError) {
@@ -222,6 +233,7 @@ const MetricWidgetBody = memo(
       <StyledMetricWidgetBody>
         <TransparentLoadingMask visible={isLoading} />
         <MetricChart
+          ref={chartRef}
           series={chartSeries}
           displayType={displayType}
           operation={metricsQuery.op}
@@ -237,7 +249,7 @@ const MetricWidgetBody = memo(
             sort={sort}
             operation={metricsQuery.op}
             onRowClick={toggleSeriesVisibility}
-            setHoveredLegend={focusedSeries ? undefined : setHoveredLegend}
+            setHoveredSeries={focusedSeries ? undefined : setHoveredSeries}
           />
         )}
       </StyledMetricWidgetBody>