Repository: getsentry/sentry
Pull request: feat(view-hierarchy): Render wireframe
Takes a view hierarchy and renders a wireframe using the x, y, width, and height coordinates using canvas. The canvas should also resize to fit its space (e.g. when the window narrows) without distorting the rendering.

When calculating the coordinates for each node, we have to take into account the position of its parents (i.e. add the x, y coordinate of the parents to its children) because the coordinates are taken relatively to the parent. Currently, if the positions provided by the SDK are very large, we don't scale them down so you'll see a relatively huge view hierarchy. I'll tackle that separately in #43872 

I also added a small hook to do panning and keep that logic separate from the component. I've added test coverage for generating the coordinates, but the mouse pan hook and the wireframe rely on clicking and dragging, these aren't easily testable in RTL right now. I will think about how to increase coverage here but it won't be in this PR.

Closes #43320 

Problem statement:
Add a wireframe view to view hierarchies
Using the x, y, width, and height of a view hierarchy window we can reconstruct the view. This should be a bunch of overlayed rectangles and the user should be able to hover over elements and select an element to see more data about that.

Hints from issue discussion:
The hovering and selecting will be part of #43875

Commit message: Merge branch 'master' into nar/feat/view-hierarchy-render-as-wireframe

Patch to review:
diff --git a/static/app/components/events/viewHierarchy/detailsPanel.tsx b/static/app/components/events/viewHierarchy/detailsPanel.tsx
index 181f82ea8a04ec..9a7095c83d8dc0 100644
--- a/static/app/components/events/viewHierarchy/detailsPanel.tsx
+++ b/static/app/components/events/viewHierarchy/detailsPanel.tsx
@@ -36,11 +36,6 @@ const Title = styled('header')`
 `;
 
 const Container = styled('div')`
-  margin-top: ${space(1)};
-  border: 1px solid ${p => p.theme.gray100};
-  border-radius: ${p => p.theme.borderRadius};
   padding: ${space(1.5)};
   padding-bottom: 0;
-  max-height: 400px;
-  overflow: auto;
 `;
diff --git a/static/app/components/events/viewHierarchy/index.tsx b/static/app/components/events/viewHierarchy/index.tsx
index bc8a10145f7127..05eee45e05a082 100644
--- a/static/app/components/events/viewHierarchy/index.tsx
+++ b/static/app/components/events/viewHierarchy/index.tsx
@@ -2,6 +2,7 @@ import {Fragment, useMemo, useState} from 'react';
 import styled from '@emotion/styled';
 
 import {Node} from 'sentry/components/events/viewHierarchy/node';
+import {Wireframe} from 'sentry/components/events/viewHierarchy/wireframe';
 import space from 'sentry/styles/space';
 import {defined} from 'sentry/utils';
 import {
@@ -110,34 +111,71 @@ function ViewHierarchy({viewHierarchy}: ViewHierarchyProps) {
   return (
     <Fragment>
       <RenderingSystem system={viewHierarchy.rendering_system} />
-      <TreeContainer>
-        <GhostRow ref={hoveredGhostRowRef} />
-        <GhostRow ref={clickedGhostRowRef} />
-        <ScrollContainer ref={setScrollContainerRef} style={scrollContainerStyles}>
-          <RenderedItemsContainer style={containerStyles}>
-            {renderedItems}
-          </RenderedItemsContainer>
-        </ScrollContainer>
-      </TreeContainer>
-      {defined(selectedNode) && (
-        <DetailsPanel data={selectedNode} getTitle={getNodeLabel} />
-      )}
+      <Content>
+        <Left>
+          <TreeContainer>
+            <GhostRow ref={hoveredGhostRowRef} />
+            <GhostRow ref={clickedGhostRowRef} />
+            <ScrollContainer ref={setScrollContainerRef} style={scrollContainerStyles}>
+              <RenderedItemsContainer style={containerStyles}>
+                {renderedItems}
+              </RenderedItemsContainer>
+            </ScrollContainer>
+          </TreeContainer>
+          {defined(selectedNode) && (
+            <DetailsContainer>
+              <DetailsPanel data={selectedNode} getTitle={getNodeLabel} />
+            </DetailsContainer>
+          )}
+        </Left>
+        <Right>
+          <Wireframe hierarchy={hierarchy} />
+        </Right>
+      </Content>
     </Fragment>
   );
 }
 
 export {ViewHierarchy};
 
+const Content = styled('div')`
+  display: flex;
+  flex-direction: row;
+  gap: ${space(1)};
+  max-height: 700px;
+`;
+
+const Left = styled('div')`
+  width: 40%;
+  display: flex;
+  gap: ${space(1)};
+  flex-direction: column;
+  resize: both;
+`;
+
+const Right = styled('div')`
+  width: 60%;
+  border: 1px solid ${p => p.theme.gray100};
+  border-radius: ${p => p.theme.borderRadius};
+  overflow: hidden;
+`;
+
 const TreeContainer = styled('div')`
   position: relative;
-  height: 400px;
+  height: 70%;
   overflow: hidden;
-  overflow-y: auto;
   background-color: ${p => p.theme.background};
   border: 1px solid ${p => p.theme.gray100};
   border-radius: ${p => p.theme.borderRadius};
 `;
 
+const DetailsContainer = styled('div')`
+  max-height: 30%;
+  border: 1px solid ${p => p.theme.gray100};
+  border-radius: ${p => p.theme.borderRadius};
+  overflow: auto;
+`;
+
 const ScrollContainer = styled('div')`
   padding: ${space(1.5)};
 `;
diff --git a/static/app/components/events/viewHierarchy/useMousePan.tsx b/static/app/components/events/viewHierarchy/useMousePan.tsx
new file mode 100644
index 00000000000000..e876460ca0ec71
--- /dev/null
+++ b/static/app/components/events/viewHierarchy/useMousePan.tsx
@@ -0,0 +1,57 @@
+import {MouseEventHandler, useState} from 'react';
+
+import {defined} from 'sentry/utils';
+
+export function useMousePan({initialXOffset, initialYOffset}) {
+  const [dragStartCoords, setDragStartCoords] = useState<{x: number; y: number} | null>(
+    null
+  );
+  const [xOffset, setXOffset] = useState(initialXOffset);
+  const [yOffset, setYOffset] = useState(initialYOffset);
+
+  const handlePanStart: MouseEventHandler<HTMLCanvasElement> = e => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    setDragStartCoords({
+      x: e.clientX - xOffset,
+      y: e.clientY - yOffset,
+    });
+  };
+
+  const handlePanStop: MouseEventHandler<HTMLCanvasElement> = e => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    setDragStartCoords(null);
+  };
+
+  const handlePanMove: MouseEventHandler<HTMLCanvasElement> = e => {
+    if (!defined(dragStartCoords)) {
+      return;
+    }
+
+    const {x, y} = dragStartCoords;
+
+    e.preventDefault();
+    e.stopPropagation();
+
+    const mouseX = e.clientX - xOffset;
+    const mouseY = e.clientY - yOffset;
+
+    const dx = mouseX - x;
+    const dy = mouseY - y;
+
+    setXOffset(xOffset + dx);
+    setYOffset(yOffset + dy);
+  };
+
+  return {
+    handlePanStart,
+    handlePanStop,
+    handlePanMove,
+    isDragging: defined(dragStartCoords),
+    xOffset,
+    yOffset,
+  };
+}
diff --git a/static/app/components/events/viewHierarchy/wireframe.spec.tsx b/static/app/components/events/viewHierarchy/wireframe.spec.tsx
new file mode 100644
index 00000000000000..ffbc50f6a5ece7
--- /dev/null
+++ b/static/app/components/events/viewHierarchy/wireframe.spec.tsx
@@ -0,0 +1,44 @@
+import {ViewHierarchyWindow} from 'sentry/components/events/viewHierarchy';
+import {getCoordinates} from 'sentry/components/events/viewHierarchy/wireframe';
+
+const MOCK_HIERARCHY = [
+  {
+    x: 0,
+    y: 0,
+    width: 10,
+    height: 10,
+    children: [
+      {
+        x: 10,
+        y: 5,
+        width: 10,
+        height: 10,
+        children: [
+          {
+            x: 2,
+            y: 2,
+            width: 5,
+            height: 5,
+          },
+        ],
+      },
+    ],
+  },
+  {x: 10, y: 0, width: 20, height: 20},
+];
+
+describe('View Hierarchy Wireframe', function () {
+  it('properly calculates coordinates', function () {
+    const actual = getCoordinates(MOCK_HIERARCHY as ViewHierarchyWindow[]);
+
+    // One array for each root
+    expect(actual).toEqual([
+      [
+        {x: 0, y: 0, width: 10, height: 10},
+        {x: 10, y: 5, width: 10, height: 10},
+        {x: 12, y: 7, width: 5, height: 5},
+      ],
+      [{x: 10, y: 0, width: 20, height: 20}],
+    ]);
+  });
+});
diff --git a/static/app/components/events/viewHierarchy/wireframe.tsx b/static/app/components/events/viewHierarchy/wireframe.tsx
new file mode 100644
index 00000000000000..f3b7590f2aafa2
--- /dev/null
+++ b/static/app/components/events/viewHierarchy/wireframe.tsx
@@ -0,0 +1,146 @@
+import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import styled from '@emotion/styled';
+import {useResizeObserver} from '@react-aria/utils';
+
+import {ViewHierarchyWindow} from 'sentry/components/events/viewHierarchy';
+import {useMousePan} from 'sentry/components/events/viewHierarchy/useMousePan';
+
+type Rect = Pick<ViewHierarchyWindow, 'x' | 'y' | 'width' | 'height'>;
+
+export function getCoordinates(hierarchies: ViewHierarchyWindow[]) {
+  return hierarchies.map(hierarchy => {
+    const newHierarchy: Rect[] = [];
+
+    const nodesToProcess = [hierarchy];
+    while (nodesToProcess.length) {
+      const node = nodesToProcess.pop();
+
+      if (!node) {
+        continue;
+      }
+
+      // Fetch coordinates off the current node
+      newHierarchy.push({
+        x: node.x ?? 0,
+        y: node.y ?? 0,
+        width: node.width ?? 0,
+        height: node.height ?? 0,
+      });
+
+      if ((node.children ?? []).length !== 0) {
+        // Update the children's coords relative to the parent
+        const newChildren =
+          node.children?.map(child => ({
+            ...child,
+            x: child.x + (node.x ?? 0),
+            y: child.y + (node.y ?? 0),
+          })) ?? [];
+
+        nodesToProcess.push(...newChildren);
+      }
+    }
+
+    return newHierarchy;
+  });
+}
+
+function Wireframe({hierarchy}) {
+  const containerRef = useRef<HTMLDivElement | null>(null);
+  const canvasRef = useRef<HTMLCanvasElement | null>(null);
+  const coordinates = useMemo(() => getCoordinates(hierarchy), [hierarchy]);
+
+  const [dimensions, setDimensions] = useState({width: 0, height: 0});
+
+  const {handlePanMove, handlePanStart, handlePanStop, isDragging, xOffset, yOffset} =
+    useMousePan({initialXOffset: 20, initialYOffset: 20});
+
+  const draw = useCallback(
+    (context: CanvasRenderingContext2D) => {
+      // Make areas with more overlayed elements darker
+      context.globalCompositeOperation = 'overlay';
+
+      coordinates.forEach(hierarchyCoords => {
+        hierarchyCoords.forEach(({x, y, width, height}) => {
+          // Prepare rectangles for drawing
+          context.fillStyle = 'rgb(88, 74, 192)';
+          context.strokeStyle = 'black';
+          context.lineWidth = 1;
+          context.rect(x + xOffset, y + yOffset, width, height);
+
+          // Draw the rectangles
+          context.globalAlpha = 0.005;
+          context.fill();
+
+          // Draw the outlines
+          context.globalAlpha = 1;
+          context.stroke();
+        });
+      });
+    },
+    [coordinates, xOffset, yOffset]
+  );
+
+  useEffect(() => {
+    const container = containerRef.current;
+    const canvas = canvasRef.current;
+    if (!canvas || !container) {
+      return;
+    }
+
+    canvas.width = dimensions.width || container.clientWidth;
+    canvas.height = dimensions.height || container.clientHeight;
+
+    const context = canvas.getContext('2d');
+    if (context) {
+      draw(context);
+    }
+  }, [dimensions.height, dimensions.width, draw]);
+
+  useResizeObserver({
+    ref: containerRef,
+    onResize: () => {
+      if (!containerRef.current) {
+        return;
+      }
+
+      const {clientWidth, clientHeight} = containerRef.current;
+      const needsResize =
+        dimensions.width !== clientWidth || dimensions.height !== clientHeight;
+
+      if (needsResize) {
+        setDimensions({
+          width: clientWidth ?? 0,
+          height: clientHeight ?? 0,
+        });
+      }
+    },
+  });
+
+  return (
+    <Container ref={containerRef}>
+      <StyledCanvas
+        ref={canvasRef}
+        onMouseDown={handlePanStart}
+        onMouseUp={handlePanStop}
+        onMouseLeave={handlePanStop}
+        onMouseMove={handlePanMove}
+        isDragging={isDragging}
+      />
+    </Container>
+  );
+}
+
+export {Wireframe};
+
+// This container wraps the canvas so we can stretch it to fit
+// the space we want and then read the width and height
+// to resize the canvas
+const Container = styled('div')`
+  height: 100%;
+  width: 100%;
+`;
+
+const StyledCanvas = styled('canvas')<{isDragging: boolean}>`
+  background-color: ${p => p.theme.surface100};
+  cursor: ${p => (p.isDragging ? 'grabbing' : 'grab')};
+`;


Code review comments:
diff_hunk: @@ -0,0 +1,146 @@
+import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import styled from '@emotion/styled';
+import {useResizeObserver} from '@react-aria/utils';
+
+import {ViewHierarchyWindow} from 'sentry/components/events/viewHierarchy';
+import {useMousePan} from 'sentry/components/events/viewHierarchy/useMousePan';
+
+type Rect = Pick<ViewHierarchyWindow, 'x' | 'y' | 'width' | 'height'>;
+
+export function getCoordinates(hierarchies: ViewHierarchyWindow[]) {
+  return hierarchies.map(hierarchy => {
+    const newHierarchy: Rect[] = [];
+
+    const nodesToProcess = [hierarchy];
+    while (nodesToProcess.length) {
+      const node = nodesToProcess.pop();
+
+      if (!node) {
+        continue;
+      }
path: static/app/components/events/viewHierarchy/wireframe.tsx
text: @user1:
I'm not sure this line is necessary, is there ever a case where node is not defined? Seems like node is of `ViewHierarchyWindow` type and it's also impossible to enter here if `nodesToProcess.length` < 1 

@author:
Typescript was complaining here because I think the return type of `pop()` is `T | undefined`. That `undefined` should've been ignored by the `while` condition but I guess it doesn't properly resolve. I went between this and `!` at the end of `pop()`. I think I'll use `!` pop since you're right, it shouldn't be possible to get undefined for `node` and it's a little distracting to have this if statement

diff_hunk: @@ -0,0 +1,57 @@
+import {MouseEventHandler, useState} from 'react';
+
+import {defined} from 'sentry/utils';
+
+export function useMousePan({initialXOffset, initialYOffset}) {
path: static/app/components/events/viewHierarchy/useMousePan.tsx
text: @user1:
should we add a type here?

diff_hunk: @@ -0,0 +1,146 @@
+import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import styled from '@emotion/styled';
+import {useResizeObserver} from '@react-aria/utils';
+
+import {ViewHierarchyWindow} from 'sentry/components/events/viewHierarchy';
+import {useMousePan} from 'sentry/components/events/viewHierarchy/useMousePan';
+
+type Rect = Pick<ViewHierarchyWindow, 'x' | 'y' | 'width' | 'height'>;
+
+export function getCoordinates(hierarchies: ViewHierarchyWindow[]) {
+  return hierarchies.map(hierarchy => {
+    const newHierarchy: Rect[] = [];
+
+    const nodesToProcess = [hierarchy];
+    while (nodesToProcess.length) {
+      const node = nodesToProcess.pop();
+
+      if (!node) {
+        continue;
+      }
+
+      // Fetch coordinates off the current node
+      newHierarchy.push({
+        x: node.x ?? 0,
+        y: node.y ?? 0,
+        width: node.width ?? 0,
+        height: node.height ?? 0,
+      });
+
+      if ((node.children ?? []).length !== 0) {
+        // Update the children's coords relative to the parent
+        const newChildren =
+          node.children?.map(child => ({
+            ...child,
+            x: child.x + (node.x ?? 0),
+            y: child.y + (node.y ?? 0),
path: static/app/components/events/viewHierarchy/wireframe.tsx
text: @user1:
also not sure if we need to check for null/undefined here with `??` since `x` and `y` should always be defined, based on the type

@author:
Okay, you're right because the type _says_ it should have x and y, but for now it looks like sometimes `x` and `y` aren't in the node. I'm going to follow this up with a PR to fix the type because it seems like some values that I'd expect to be there might not be.