Browse Source

feat(span-tree): Horizontal autoscrolling on the span tree (#34706)

This PR adds a new feature to the span tree, that will cause the view to adjust horizontally as the user scrolls down the tree.
Ash Anand 2 years ago
parent
commit
bbadcbb110

+ 93 - 53
static/app/components/events/interfaces/spans/scrollbarManager.tsx

@@ -1,4 +1,5 @@
 import {Component, createContext, createRef} from 'react';
+import throttle from 'lodash/throttle';
 
 import {
   clamp,
@@ -9,9 +10,12 @@ import getDisplayName from 'sentry/utils/getDisplayName';
 import {setBodyUserSelect, UserSelectValues} from 'sentry/utils/userselect';
 
 import {DragManagerChildrenProps} from './dragManager';
+import {SpansInViewMap} from './utils';
 
 export type ScrollbarManagerChildrenProps = {
   generateContentSpanBarRef: () => (instance: HTMLDivElement | null) => void;
+  markSpanInView: (spanId: string, treeDepth: number) => void;
+  markSpanOutOfView: (spanId: string) => void;
   onDragStart: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
   onScroll: () => void;
   onWheel: (deltaX: number) => void;
@@ -28,6 +32,8 @@ const ScrollbarManagerContext = createContext<ScrollbarManagerChildrenProps>({
   onScroll: () => {},
   onWheel: () => {},
   updateScrollState: () => {},
+  markSpanOutOfView: () => {},
+  markSpanInView: () => {},
 });
 
 const selectRefs = (
@@ -109,7 +115,9 @@ export class Provider extends Component<Props, State> {
   isDragging: boolean = false;
   isWheeling: boolean = false;
   wheelTimeout: NodeJS.Timeout | null = null;
+  animationTimeout: NodeJS.Timeout | null = null;
   previousUserSelect: UserSelectValues | null = null;
+  spansInView: SpansInViewMap = new SpansInViewMap();
 
   getReferenceSpanBar() {
     for (const currentSpanBar of this.contentSpanBar) {
@@ -237,35 +245,13 @@ export class Provider extends Component<Props, State> {
   hasInteractiveLayer = (): boolean => !!this.props.interactiveLayerRef.current;
   initialMouseClickX: number | undefined = undefined;
 
-  onWheel = (deltaX: number) => {
-    if (this.isDragging || !this.hasInteractiveLayer()) {
-      return;
+  performScroll = (scrollLeft: number, isAnimated?: boolean) => {
+    if (isAnimated) {
+      this.startAnimation();
     }
 
-    // Setting this here is necessary, since updating the virtual scrollbar position will also trigger the onScroll function
-    this.isWheeling = true;
-
-    if (this.wheelTimeout) {
-      clearTimeout(this.wheelTimeout);
-    }
-
-    this.wheelTimeout = setTimeout(() => {
-      this.isWheeling = false;
-      this.wheelTimeout = null;
-    }, 200);
-
     const interactiveLayerRefDOM = this.props.interactiveLayerRef.current!;
-
     const interactiveLayerRect = interactiveLayerRefDOM.getBoundingClientRect();
-    const maxScrollLeft =
-      interactiveLayerRefDOM.scrollWidth - interactiveLayerRefDOM.clientWidth;
-
-    const scrollLeft = clamp(
-      interactiveLayerRefDOM.scrollLeft + deltaX,
-      0,
-      maxScrollLeft
-    );
-
     interactiveLayerRefDOM.scrollLeft = scrollLeft;
 
     // Update scroll position of the virtual scroll bar
@@ -282,7 +268,7 @@ export class Provider extends Component<Props, State> {
           clamp(virtualScrollbarPosition, 0, maxVirtualScrollableArea) *
           interactiveLayerRect.width;
 
-        virtualScrollbarDOM.style.transform = `translate3d(${virtualLeft}px, 0, 0)`;
+        virtualScrollbarDOM.style.transform = `translateX(${virtualLeft}px)`;
         virtualScrollbarDOM.style.transformOrigin = 'left';
       });
     });
@@ -291,47 +277,56 @@ export class Provider extends Component<Props, State> {
     selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => {
       const left = -scrollLeft;
 
-      spanBarDOM.style.transform = `translate3d(${left}px, 0, 0)`;
+      spanBarDOM.style.transform = `translateX(${left}px)`;
       spanBarDOM.style.transformOrigin = 'left';
     });
   };
 
-  onScroll = () => {
-    if (this.isDragging || this.isWheeling || !this.hasInteractiveLayer()) {
+  // Throttle the scroll function to prevent jankiness in the auto-adjust animations when scrolling fast
+  throttledScroll = throttle(this.performScroll, 300, {trailing: true});
+
+  onWheel = (deltaX: number) => {
+    if (this.isDragging || !this.hasInteractiveLayer()) {
       return;
     }
 
-    const interactiveLayerRefDOM = this.props.interactiveLayerRef.current!;
+    this.disableAnimation();
 
-    const interactiveLayerRect = interactiveLayerRefDOM.getBoundingClientRect();
-    const scrollLeft = interactiveLayerRefDOM.scrollLeft;
+    // Setting this here is necessary, since updating the virtual scrollbar position will also trigger the onScroll function
+    this.isWheeling = true;
 
-    // Update scroll position of the virtual scroll bar
-    selectRefs(this.scrollBarArea, (scrollBarAreaDOM: HTMLDivElement) => {
-      selectRefs(this.virtualScrollbar, (virtualScrollbarDOM: HTMLDivElement) => {
-        const scrollBarAreaRect = scrollBarAreaDOM.getBoundingClientRect();
-        const virtualScrollbarPosition = scrollLeft / scrollBarAreaRect.width;
+    if (this.wheelTimeout) {
+      clearTimeout(this.wheelTimeout);
+    }
 
-        const virtualScrollBarRect = rectOfContent(virtualScrollbarDOM);
-        const maxVirtualScrollableArea =
-          1 - virtualScrollBarRect.width / interactiveLayerRect.width;
+    this.wheelTimeout = setTimeout(() => {
+      this.isWheeling = false;
+      this.wheelTimeout = null;
+    }, 200);
 
-        const virtualLeft =
-          clamp(virtualScrollbarPosition, 0, maxVirtualScrollableArea) *
-          interactiveLayerRect.width;
+    const interactiveLayerRefDOM = this.props.interactiveLayerRef.current!;
 
-        virtualScrollbarDOM.style.transform = `translate3d(${virtualLeft}px, 0, 0)`;
-        virtualScrollbarDOM.style.transformOrigin = 'left';
-      });
-    });
+    const maxScrollLeft =
+      interactiveLayerRefDOM.scrollWidth - interactiveLayerRefDOM.clientWidth;
 
-    // Update scroll positions of all the span bars
-    selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => {
-      const left = -scrollLeft;
+    const scrollLeft = clamp(
+      interactiveLayerRefDOM.scrollLeft + deltaX,
+      0,
+      maxScrollLeft
+    );
 
-      spanBarDOM.style.transform = `translate3d(${left}px, 0, 0)`;
-      spanBarDOM.style.transformOrigin = 'left';
-    });
+    this.performScroll(scrollLeft);
+  };
+
+  onScroll = () => {
+    if (this.isDragging || this.isWheeling || !this.hasInteractiveLayer()) {
+      return;
+    }
+
+    const interactiveLayerRefDOM = this.props.interactiveLayerRef.current!;
+    const scrollLeft = interactiveLayerRefDOM.scrollLeft;
+
+    this.performScroll(scrollLeft);
   };
 
   onDragStart = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
@@ -473,6 +468,49 @@ export class Provider extends Component<Props, State> {
     }
   };
 
+  markSpanOutOfView = (spanId: string) => {
+    if (!this.spansInView.removeSpan(spanId)) {
+      return;
+    }
+
+    const left = this.spansInView.getScrollVal();
+    this.throttledScroll(left, true);
+  };
+
+  markSpanInView = (spanId: string, treeDepth: number) => {
+    if (!this.spansInView.addSpan(spanId, treeDepth)) {
+      return;
+    }
+
+    const left = this.spansInView.getScrollVal();
+    this.throttledScroll(left, true);
+  };
+
+  startAnimation() {
+    selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => {
+      spanBarDOM.style.transition = 'transform 0.3s';
+    });
+
+    if (this.animationTimeout) {
+      clearTimeout(this.animationTimeout);
+    }
+
+    // This timeout is set to trigger immediately after the animation ends, to disable the animation.
+    // The animation needs to be cleared, otherwise manual horizontal scrolling will be animated
+    this.animationTimeout = setTimeout(() => {
+      selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => {
+        spanBarDOM.style.transition = '';
+      });
+      this.animationTimeout = null;
+    }, 300);
+  }
+
+  disableAnimation() {
+    selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => {
+      spanBarDOM.style.transition = '';
+    });
+  }
+
   render() {
     const childrenProps: ScrollbarManagerChildrenProps = {
       generateContentSpanBarRef: this.generateContentSpanBarRef,
@@ -482,6 +520,8 @@ export class Provider extends Component<Props, State> {
       virtualScrollbarRef: this.virtualScrollbar,
       scrollBarAreaRef: this.scrollBarArea,
       updateScrollState: this.initializeScrollState,
+      markSpanOutOfView: this.markSpanOutOfView,
+      markSpanInView: this.markSpanInView,
     };
 
     return (

+ 44 - 1
static/app/components/events/interfaces/spans/spanBar.tsx

@@ -100,7 +100,7 @@ const INTERSECTION_THRESHOLDS: Array<number> = [
   0.9, 0.91, 0.92, 0.93, 0.94, 0.95, 0.96, 0.97, 0.98, 0.99, 1.0,
 ];
 
-const MARGIN_LEFT = 0;
+export const MARGIN_LEFT = 0;
 
 type SpanBarProps = {
   continuingTreeDepths: Array<TreeDepthType>;
@@ -109,6 +109,8 @@ type SpanBarProps = {
   generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType;
   generateContentSpanBarRef: () => (instance: HTMLDivElement | null) => void;
   isEmbeddedTransactionTimeAdjusted: boolean;
+  markSpanInView: (spanId: string, treeDepth: number) => void;
+  markSpanOutOfView: (spanId: string) => void;
   numOfSpanChildren: number;
   numOfSpans: number;
   onWheel: (deltaX: number) => void;
@@ -162,6 +164,13 @@ class SpanBar extends Component<SpanBarProps, SpanBarState> {
     if (this.spanTitleRef.current) {
       this.spanTitleRef.current.removeEventListener('wheel', this.handleWheel);
     }
+
+    const {span} = this.props;
+    if ('type' in span) {
+      return;
+    }
+
+    this.props.markSpanOutOfView(span.span_id);
   }
 
   spanRowDOMRef = createRef<HTMLDivElement>();
@@ -179,6 +188,11 @@ class SpanBar extends Component<SpanBarProps, SpanBarState> {
 
     event.preventDefault();
     event.stopPropagation();
+
+    if (Math.abs(event.deltaY) === Math.abs(event.deltaX)) {
+      return;
+    }
+
     const {onWheel} = this.props;
     onWheel(event.deltaX);
   };
@@ -640,6 +654,23 @@ class SpanBar extends Component<SpanBarProps, SpanBarState> {
             relativeToMinimap.top > 0 && relativeToMinimap.bottom > 0;
 
           if (rectBelowMinimap) {
+            const {span, treeDepth, organization} = this.props;
+            if ('type' in span) {
+              return;
+            }
+
+            if (this.props.numOfSpanChildren !== 0) {
+              // TODO: Remove this check when this feature is GA'd
+              if (organization.features.includes('performance-span-tree-autoscroll')) {
+                // If isIntersecting is false, this means the span is out of view below the viewport
+                if (!entry.isIntersecting) {
+                  this.props.markSpanOutOfView(span.span_id);
+                } else {
+                  this.props.markSpanInView(span.span_id, treeDepth);
+                }
+              }
+            }
+
             // if the first span is below the minimap, we scroll the minimap
             // to the top. this addresses spurious scrolling to the top of the page
             if (spanNumber <= 1) {
@@ -652,6 +683,18 @@ class SpanBar extends Component<SpanBarProps, SpanBarState> {
           const inAndAboveMinimap = relativeToMinimap.bottom <= 0;
 
           if (inAndAboveMinimap) {
+            const {span, organization} = this.props;
+            if ('type' in span) {
+              return;
+            }
+
+            if (this.props.numOfSpanChildren !== 0) {
+              // TODO: Remove this check when this feature is GA'd
+              if (organization.features.includes('performance-span-tree-autoscroll')) {
+                this.props.markSpanOutOfView(span.span_id);
+              }
+            }
+
             return;
           }
 

+ 4 - 0
static/app/components/events/interfaces/spans/spanGroupBar.tsx

@@ -154,6 +154,10 @@ export function SpanGroupBar(props: Props) {
       event.preventDefault();
       event.stopPropagation();
 
+      if (Math.abs(event.deltaY) === Math.abs(event.deltaX)) {
+        return;
+      }
+
       onWheel(event.deltaX);
     };
 

+ 4 - 0
static/app/components/events/interfaces/spans/spanTree.tsx

@@ -157,6 +157,8 @@ class SpanTree extends Component<PropType> {
       dragProps,
       onWheel,
       generateContentSpanBarRef,
+      markSpanOutOfView,
+      markSpanInView,
     } = this.props;
     const generateBounds = waterfallModel.generateBounds({
       viewStart: dragProps.viewWindowStart,
@@ -317,6 +319,8 @@ class SpanTree extends Component<PropType> {
             isEmbeddedTransactionTimeAdjusted={payload.isEmbeddedTransactionTimeAdjusted}
             onWheel={onWheel}
             generateContentSpanBarRef={generateContentSpanBarRef}
+            markSpanOutOfView={markSpanOutOfView}
+            markSpanInView={markSpanInView}
           />
         );
 

+ 69 - 0
static/app/components/events/interfaces/spans/utils.tsx

@@ -5,12 +5,14 @@ import isString from 'lodash/isString';
 import set from 'lodash/set';
 import moment from 'moment';
 
+import {TOGGLE_BORDER_BOX} from 'sentry/components/performance/waterfall/treeConnector';
 import {EntryType, EventTransaction} from 'sentry/types/event';
 import {assert} from 'sentry/types/utils';
 import {WEB_VITAL_DETAILS} from 'sentry/utils/performance/vitals/constants';
 import {getPerformanceTransaction} from 'sentry/utils/performanceForSentry';
 
 import {MERGE_LABELS_THRESHOLD_PERCENT} from './constants';
+import {MARGIN_LEFT} from './spanBar';
 import {
   EnhancedSpan,
   GapSpanType,
@@ -761,3 +763,70 @@ export function getSpanGroupBounds(
     }
   }
 }
+
+export class SpansInViewMap {
+  spanDepthsInView: Map<string, number>;
+  treeDepthSum: number;
+  length: number;
+  isRootSpanInView: boolean;
+
+  constructor() {
+    this.spanDepthsInView = new Map();
+    this.treeDepthSum = 0;
+    this.length = 0;
+    this.isRootSpanInView = true;
+  }
+
+  /**
+   *
+   * @param spanId
+   * @param treeDepth
+   * @returns false if the span is already stored, true otherwise
+   */
+  addSpan(spanId: string, treeDepth: number): boolean {
+    if (this.spanDepthsInView.has(spanId)) {
+      return false;
+    }
+
+    this.spanDepthsInView.set(spanId, treeDepth);
+    this.length += 1;
+    this.treeDepthSum += treeDepth;
+
+    if (treeDepth === 0) {
+      this.isRootSpanInView = true;
+    }
+
+    return true;
+  }
+
+  /**
+   *
+   * @param spanId
+   * @returns false if the span does not exist within the span, true otherwise
+   */
+  removeSpan(spanId: string): boolean {
+    if (!this.spanDepthsInView.has(spanId)) {
+      return false;
+    }
+
+    const treeDepth = this.spanDepthsInView.get(spanId);
+    this.spanDepthsInView.delete(spanId);
+    this.length -= 1;
+    this.treeDepthSum -= treeDepth!;
+
+    if (treeDepth === 0) {
+      this.isRootSpanInView = false;
+    }
+
+    return true;
+  }
+
+  getScrollVal() {
+    if (this.isRootSpanInView) {
+      return 0;
+    }
+
+    const avgDepth = Math.round(this.treeDepthSum / this.length);
+    return avgDepth * (TOGGLE_BORDER_BOX / 2) + MARGIN_LEFT;
+  }
+}