Просмотр исходного кода

feat(view-hierarchy): Add zooming (#44445)

Allow for zooming in/out by clicking the +/- buttons, or pressing
Cmd/Ctrl and scrolling.

Closes #43873
Nar Saynorath 2 лет назад
Родитель
Сommit
248546e68d

+ 87 - 8
static/app/components/events/viewHierarchy/wireframe.tsx

@@ -3,6 +3,7 @@ import {useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 import {mat3, vec2} from 'gl-matrix';
 
+import {Button} from 'sentry/components/button';
 import {ViewHierarchyWindow} from 'sentry/components/events/viewHierarchy';
 import {
   calculateScale,
@@ -10,7 +11,13 @@ import {
   getHierarchyDimensions,
   useResizeCanvasObserver,
 } from 'sentry/components/events/viewHierarchy/utils';
-import {Rect} from 'sentry/utils/profiling/gl/utils';
+import {IconAdd, IconSubtract} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {
+  getCenterScaleMatrixFromConfigPosition,
+  Rect,
+} from 'sentry/utils/profiling/gl/utils';
 
 const MIN_BORDER_SIZE = 20;
 
@@ -29,6 +36,8 @@ function Wireframe({hierarchy, selectedNode, onNodeSelect}: WireframeProps) {
   const theme = useTheme();
   const [canvasRef, setCanvasRef] = useState<HTMLCanvasElement | null>(null);
   const [overlayRef, setOverlayRef] = useState<HTMLCanvasElement | null>(null);
+  const [zoomIn, setZoomIn] = useState<HTMLButtonElement | null>(null);
+  const [zoomOut, setZoomOut] = useState<HTMLButtonElement | null>(null);
 
   const canvases = useMemo(() => {
     return canvasRef && overlayRef ? [canvasRef, overlayRef] : [];
@@ -149,7 +158,7 @@ function Wireframe({hierarchy, selectedNode, onNodeSelect}: WireframeProps) {
   );
 
   useEffect(() => {
-    if (!canvasRef || !overlayRef) {
+    if (!canvasRef || !overlayRef || !zoomIn || !zoomOut) {
       return undefined;
     }
 
@@ -159,6 +168,7 @@ function Wireframe({hierarchy, selectedNode, onNodeSelect}: WireframeProps) {
       (selectedNode && nodeLookupMap.get(selectedNode)?.rect) ?? null;
     let hoveredRect: Rect | null = null;
     const currTransformationMatrix = mat3.clone(transformationMatrix);
+    const lastMousePosition = vec2.create();
 
     const handleMouseDown = (e: MouseEvent) => {
       start = vec2.fromValues(e.offsetX, e.offsetY);
@@ -174,7 +184,7 @@ function Wireframe({hierarchy, selectedNode, onNodeSelect}: WireframeProps) {
         // Delta needs to be scaled by the devicePixelRatio and how
         // much we've zoomed the image by to get an accurate translation
         const delta = vec2.sub(vec2.create(), currPosition, start);
-        vec2.scale(delta, delta, window.devicePixelRatio / scale);
+        vec2.scale(delta, delta, window.devicePixelRatio / transformationMatrix[0]);
 
         // Translate from the original matrix as a starting point
         mat3.translate(currTransformationMatrix, transformationMatrix, delta);
@@ -190,6 +200,8 @@ function Wireframe({hierarchy, selectedNode, onNodeSelect}: WireframeProps) {
           )?.rect ?? null;
         drawOverlay(transformationMatrix, selectedRect, hoveredRect);
       }
+      vec2.copy(lastMousePosition, vec2.fromValues(e.offsetX, e.offsetY));
+      vec2.scale(lastMousePosition, lastMousePosition, window.devicePixelRatio);
     };
 
     const handleMouseUp = () => {
@@ -220,13 +232,48 @@ function Wireframe({hierarchy, selectedNode, onNodeSelect}: WireframeProps) {
       isDragging = false;
     };
 
+    const handleZoom =
+      (direction: 'in' | 'out', scalingFactor: number = 1.1, zoomOrigin?: vec2) =>
+      () => {
+        const newScale = direction === 'in' ? scalingFactor : 1 / scalingFactor;
+
+        // Generate a scaling matrix that also accounts for the zoom origin
+        // so when the scale is applied, the zoom origin stays in the same place
+        // i.e. cursor position or center of the canvas
+        const center = vec2.fromValues(canvasSize.width / 2, canvasSize.height / 2);
+        const origin = zoomOrigin ?? center;
+        const scaleMatrix = getCenterScaleMatrixFromConfigPosition(
+          vec2.fromValues(newScale, newScale),
+          origin
+        );
+        mat3.multiply(currTransformationMatrix, scaleMatrix, currTransformationMatrix);
+
+        drawViewHierarchy(currTransformationMatrix);
+        drawOverlay(currTransformationMatrix, selectedRect, hoveredRect);
+        mat3.copy(transformationMatrix, currTransformationMatrix);
+      };
+
+    const handleWheel = (e: WheelEvent) => {
+      if (e.ctrlKey || e.metaKey) {
+        e.preventDefault();
+        handleZoom(e.deltaY > 0 ? 'out' : 'in', 1.05, lastMousePosition)();
+      }
+    };
+
     const options: AddEventListenerOptions & EventListenerOptions = {passive: true};
+    const onwheelOptions: AddEventListenerOptions & EventListenerOptions = {
+      passive: false,
+    };
 
     overlayRef.addEventListener('mousedown', handleMouseDown, options);
     overlayRef.addEventListener('mousemove', handleMouseMove, options);
     overlayRef.addEventListener('mouseup', handleMouseUp, options);
     overlayRef.addEventListener('click', handleMouseClick, options);
 
+    zoomIn.addEventListener('click', handleZoom('in'), options);
+    zoomOut.addEventListener('click', handleZoom('out'), options);
+    overlayRef.addEventListener('wheel', handleWheel, onwheelOptions);
+
     drawViewHierarchy(transformationMatrix);
     drawOverlay(transformationMatrix, selectedRect, hoveredRect);
 
@@ -235,6 +282,10 @@ function Wireframe({hierarchy, selectedNode, onNodeSelect}: WireframeProps) {
       overlayRef.removeEventListener('mousemove', handleMouseMove, options);
       overlayRef.removeEventListener('mouseup', handleMouseUp, options);
       overlayRef.removeEventListener('click', handleMouseClick, options);
+
+      zoomIn.removeEventListener('click', handleZoom('in'), options);
+      zoomOut.removeEventListener('click', handleZoom('out'), options);
+      overlayRef.removeEventListener('wheel', handleWheel, onwheelOptions);
     };
   }, [
     transformationMatrix,
@@ -247,14 +298,28 @@ function Wireframe({hierarchy, selectedNode, onNodeSelect}: WireframeProps) {
     drawOverlay,
     selectedNode,
     nodeLookupMap,
+    zoomIn,
+    zoomOut,
+    canvasSize.width,
+    canvasSize.height,
   ]);
 
   return (
     <Stack>
-      <InteractionOverlayCanvas
-        data-test-id="view-hierarchy-wireframe-overlay"
-        ref={r => setOverlayRef(r)}
-      />
+      <InteractionContainer>
+        <Controls>
+          <Button size="xs" ref={setZoomIn} aria-label={t('Zoom In on wireframe')}>
+            <IconAdd size="xs" />
+          </Button>
+          <Button size="xs" ref={setZoomOut} aria-label={t('Zoom Out on wireframe')}>
+            <IconSubtract size="xs" />
+          </Button>
+        </Controls>
+        <InteractionOverlayCanvas
+          data-test-id="view-hierarchy-wireframe-overlay"
+          ref={r => setOverlayRef(r)}
+        />
+      </InteractionContainer>
       <WireframeCanvas
         data-test-id="view-hierarchy-wireframe"
         ref={r => setCanvasRef(r)}
@@ -271,10 +336,24 @@ const Stack = styled('div')`
   width: 100%;
 `;
 
-const InteractionOverlayCanvas = styled('canvas')`
+const InteractionContainer = styled('div')`
   position: absolute;
   top: 0;
   left: 0;
+  height: 100%;
+  width: 100%;
+`;
+
+const Controls = styled('div')`
+  position: absolute;
+  top: ${space(2)};
+  right: ${space(2)};
+  display: flex;
+  flex-direction: column;
+  gap: ${space(0.5)};
+`;
+
+const InteractionOverlayCanvas = styled('canvas')`
   width: 100%;
   height: 100%;
 `;

+ 2 - 2
static/app/components/profiling/flamegraph/interactions/useViewKeyboardNavigation.tsx

@@ -39,7 +39,7 @@ export function useViewKeyboardNavigation(
         }
         canvasPoolManager.dispatch('transform config view', [
           getCenterScaleMatrixFromConfigPosition(
-            0.99 * inertia.current,
+            vec2.fromValues(0.99 * inertia.current, 1),
             vec2.fromValues(view.configView.centerX, view.configView.y)
           ),
           view,
@@ -53,7 +53,7 @@ export function useViewKeyboardNavigation(
         }
         canvasPoolManager.dispatch('transform config view', [
           getCenterScaleMatrixFromConfigPosition(
-            1.01 * inertia.current,
+            vec2.fromValues(1.01 * inertia.current, 1),
             vec2.fromValues(view.configView.centerX, view.configView.y)
           ),
           view,

+ 36 - 0
static/app/utils/profiling/gl/utils.spec.tsx

@@ -8,6 +8,7 @@ import {
   createShader,
   ELLIPSIS,
   findRangeBinarySearch,
+  getCenterScaleMatrixFromConfigPosition,
   getContext,
   lowerBound,
   makeProjectionMatrix,
@@ -613,4 +614,39 @@ describe('computeConfigViewWithStrategy', () => {
       computeConfigViewWithStrategy('min', view, frame).equals(new Rect(0, 2, 10, 1))
     ).toBe(true);
   });
+
+  describe('getCenterScaleMatrixFromConfigPosition', function () {
+    it('returns a matrix that represents scaling on both x and y axes', function () {
+      const actual = getCenterScaleMatrixFromConfigPosition(
+        vec2.fromValues(2, 2),
+        vec2.fromValues(0, 0)
+      );
+
+      // Scales by 2 along the x and y axis
+      expect(actual).toEqual(
+        // prettier-ignore
+        mat3.fromValues(
+          2, 0, 0,
+          0, 2, 0,
+          0, 0, 1
+        )
+      );
+    });
+
+    it('returns a matrix that scales and translates back so the scaling appears to zoom into the point', function () {
+      const actual = getCenterScaleMatrixFromConfigPosition(
+        vec2.fromValues(2, 2),
+        vec2.fromValues(5, 5)
+      );
+
+      expect(actual).toEqual(
+        // prettier-ignore
+        mat3.fromValues(
+          2, 0, 0,
+          0, 2, 0,
+          -5, -5, 1
+        )
+      );
+    });
+  });
 });

+ 3 - 3
static/app/utils/profiling/gl/utils.ts

@@ -868,12 +868,12 @@ export function getPhysicalSpacePositionFromOffset(offsetX: number, offsetY: num
   return vec2.scale(vec2.create(), logicalMousePos, window.devicePixelRatio);
 }
 
-export function getCenterScaleMatrixFromConfigPosition(scale: number, center: vec2) {
+export function getCenterScaleMatrixFromConfigPosition(scale: vec2, center: vec2) {
   const invertedConfigCenter = vec2.fromValues(-center[0], -center[1]);
 
   const centerScaleMatrix = mat3.create();
   mat3.fromTranslation(centerScaleMatrix, center);
-  mat3.scale(centerScaleMatrix, centerScaleMatrix, vec2.fromValues(scale, 1));
+  mat3.scale(centerScaleMatrix, centerScaleMatrix, scale);
   mat3.translate(centerScaleMatrix, centerScaleMatrix, invertedConfigCenter);
   return centerScaleMatrix;
 }
@@ -889,7 +889,7 @@ export function getCenterScaleMatrixFromMousePosition(
   const configSpaceMouse = view.getConfigViewCursor(cursor, canvas);
 
   const configCenter = vec2.fromValues(configSpaceMouse[0], view.configView.y);
-  return getCenterScaleMatrixFromConfigPosition(scale, configCenter);
+  return getCenterScaleMatrixFromConfigPosition(vec2.fromValues(scale, 1), configCenter);
 }
 
 export function getTranslationMatrixFromConfigSpace(deltaX: number, deltaY: number) {