wireframe.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. import {useCallback, useEffect, useMemo, useState} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {mat3, vec2} from 'gl-matrix';
  5. import {Button} from 'sentry/components/button';
  6. import type {ViewHierarchyWindow} from 'sentry/components/events/viewHierarchy';
  7. import {
  8. calculateScale,
  9. getDeepestNodeAtPoint,
  10. getHierarchyDimensions,
  11. useResizeCanvasObserver,
  12. } from 'sentry/components/events/viewHierarchy/utils';
  13. import {IconAdd, IconSubtract} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import type {Project} from 'sentry/types/project';
  17. import {getCenterScaleMatrixFromConfigPosition} from 'sentry/utils/profiling/gl/utils';
  18. import type {Rect} from 'sentry/utils/profiling/speedscope';
  19. const MIN_BORDER_SIZE = 20;
  20. const MOUSE_DRAG_THRESHOLD = 3;
  21. export interface ViewNode {
  22. node: ViewHierarchyWindow;
  23. rect: Rect;
  24. }
  25. type WireframeProps = {
  26. hierarchy: ViewHierarchyWindow[];
  27. onNodeSelect: (node?: ViewHierarchyWindow) => void;
  28. project: Project;
  29. selectedNode?: ViewHierarchyWindow;
  30. };
  31. function Wireframe({hierarchy, selectedNode, onNodeSelect, project}: WireframeProps) {
  32. const theme = useTheme();
  33. const [canvasRef, setCanvasRef] = useState<HTMLCanvasElement | null>(null);
  34. const [overlayRef, setOverlayRef] = useState<HTMLCanvasElement | null>(null);
  35. const [zoomIn, setZoomIn] = useState<HTMLElement | null>(null);
  36. const [zoomOut, setZoomOut] = useState<HTMLElement | null>(null);
  37. const canvases = useMemo(() => {
  38. return canvasRef && overlayRef ? [canvasRef, overlayRef] : [];
  39. }, [canvasRef, overlayRef]);
  40. const canvasSize = useResizeCanvasObserver(canvases);
  41. const hierarchyData = useMemo(
  42. () =>
  43. getHierarchyDimensions(
  44. hierarchy,
  45. ['flutter', 'dart-flutter'].includes(project?.platform ?? '')
  46. ),
  47. [hierarchy, project.platform]
  48. );
  49. const nodeLookupMap = useMemo(() => {
  50. const map = new Map<ViewHierarchyWindow, ViewNode>();
  51. hierarchyData.nodes.forEach(node => {
  52. map.set(node.node, node);
  53. });
  54. return map;
  55. }, [hierarchyData.nodes]);
  56. const scale = useMemo(() => {
  57. return calculateScale(
  58. {width: canvasSize.width, height: canvasSize.height},
  59. {width: hierarchyData.maxWidth, height: hierarchyData.maxHeight},
  60. {
  61. x: MIN_BORDER_SIZE,
  62. y: MIN_BORDER_SIZE,
  63. }
  64. );
  65. }, [
  66. canvasSize.height,
  67. canvasSize.width,
  68. hierarchyData.maxHeight,
  69. hierarchyData.maxWidth,
  70. ]);
  71. const transformationMatrix = useMemo(() => {
  72. const xCenter = Math.abs(canvasSize.width - hierarchyData.maxWidth * scale) / 2;
  73. const yCenter = Math.abs(canvasSize.height - hierarchyData.maxHeight * scale) / 2;
  74. // prettier-ignore
  75. return mat3.fromValues(scale, 0, 0, 0, scale, 0, xCenter, yCenter, 1);
  76. }, [
  77. canvasSize.height,
  78. canvasSize.width,
  79. hierarchyData.maxHeight,
  80. hierarchyData.maxWidth,
  81. scale,
  82. ]);
  83. const setupCanvasContext = useCallback(
  84. (context: CanvasRenderingContext2D, modelToView: mat3) => {
  85. context.resetTransform();
  86. context.clearRect(0, 0, canvasSize.width, canvasSize.height);
  87. context.setTransform(
  88. modelToView[0],
  89. modelToView[3],
  90. modelToView[1],
  91. modelToView[4],
  92. modelToView[6],
  93. modelToView[7]
  94. );
  95. },
  96. [canvasSize.height, canvasSize.width]
  97. );
  98. const drawOverlay = useCallback(
  99. (modelToView: mat3, selectedRect: Rect | null, hoverRect: Rect | null) => {
  100. const overlay = overlayRef?.getContext('2d');
  101. if (overlay) {
  102. setupCanvasContext(overlay, modelToView);
  103. overlay.fillStyle = theme.blue200;
  104. if (selectedRect) {
  105. overlay.fillRect(
  106. selectedRect.x,
  107. selectedRect.y,
  108. selectedRect.width,
  109. selectedRect.height
  110. );
  111. }
  112. if (hoverRect) {
  113. overlay.fillStyle = theme.blue100;
  114. overlay.fillRect(hoverRect.x, hoverRect.y, hoverRect.width, hoverRect.height);
  115. }
  116. }
  117. },
  118. [overlayRef, setupCanvasContext, theme.blue100, theme.blue200]
  119. );
  120. const drawViewHierarchy = useCallback(
  121. (modelToView: mat3) => {
  122. const canvas = canvasRef?.getContext('2d');
  123. if (canvas) {
  124. setupCanvasContext(canvas, modelToView);
  125. canvas.fillStyle = theme.gray100;
  126. canvas.strokeStyle = theme.gray300;
  127. for (let i = 0; i < hierarchyData.nodes.length; i++) {
  128. canvas.strokeRect(
  129. hierarchyData.nodes[i].rect.x,
  130. hierarchyData.nodes[i].rect.y,
  131. hierarchyData.nodes[i].rect.width,
  132. hierarchyData.nodes[i].rect.height
  133. );
  134. canvas.fillRect(
  135. hierarchyData.nodes[i].rect.x,
  136. hierarchyData.nodes[i].rect.y,
  137. hierarchyData.nodes[i].rect.width,
  138. hierarchyData.nodes[i].rect.height
  139. );
  140. }
  141. }
  142. },
  143. [canvasRef, setupCanvasContext, theme.gray100, theme.gray300, hierarchyData.nodes]
  144. );
  145. useEffect(() => {
  146. if (!canvasRef || !overlayRef || !zoomIn || !zoomOut) {
  147. return undefined;
  148. }
  149. let start: vec2 | null;
  150. let isDragging = false;
  151. const selectedRect: Rect | null =
  152. (selectedNode && nodeLookupMap.get(selectedNode)?.rect) ?? null;
  153. let hoveredRect: Rect | null = null;
  154. const currTransformationMatrix = mat3.clone(transformationMatrix);
  155. const lastMousePosition = vec2.create();
  156. const handleMouseDown = (e: MouseEvent) => {
  157. start = vec2.fromValues(e.offsetX, e.offsetY);
  158. };
  159. const handleMouseMove = (e: MouseEvent) => {
  160. if (start) {
  161. const currPosition = vec2.fromValues(e.offsetX, e.offsetY);
  162. const delta = vec2.sub(vec2.create(), currPosition, start);
  163. // If the mouse hasn't moved significantly, then don't consider
  164. // this a drag. This prevents missed selections when the user
  165. // moves their mouse slightly when clicking
  166. const distance = vec2.len(delta);
  167. if (!isDragging && distance < MOUSE_DRAG_THRESHOLD) {
  168. return;
  169. }
  170. overlayRef.style.cursor = 'grabbing';
  171. isDragging = true;
  172. hoveredRect = null;
  173. // Delta needs to be scaled by the devicePixelRatio and how
  174. // much we've zoomed the image by to get an accurate translation
  175. vec2.scale(delta, delta, window.devicePixelRatio / transformationMatrix[0]);
  176. // Translate from the original matrix as a starting point
  177. mat3.translate(currTransformationMatrix, transformationMatrix, delta);
  178. drawViewHierarchy(currTransformationMatrix);
  179. drawOverlay(currTransformationMatrix, selectedRect, hoveredRect);
  180. } else {
  181. hoveredRect =
  182. getDeepestNodeAtPoint(
  183. hierarchyData.nodes,
  184. vec2.fromValues(e.offsetX, e.offsetY),
  185. currTransformationMatrix,
  186. window.devicePixelRatio
  187. )?.rect ?? null;
  188. drawOverlay(transformationMatrix, selectedRect, hoveredRect);
  189. }
  190. vec2.copy(lastMousePosition, vec2.fromValues(e.offsetX, e.offsetY));
  191. vec2.scale(lastMousePosition, lastMousePosition, window.devicePixelRatio);
  192. };
  193. const handleMouseUp = () => {
  194. if (isDragging) {
  195. // The panning has ended, store its transformations into the original matrix
  196. mat3.copy(transformationMatrix, currTransformationMatrix);
  197. }
  198. start = null;
  199. overlayRef.style.cursor = 'pointer';
  200. };
  201. const handleMouseClick = (e: MouseEvent) => {
  202. if (!isDragging) {
  203. const clickedNode = getDeepestNodeAtPoint(
  204. hierarchyData.nodes,
  205. vec2.fromValues(e.offsetX, e.offsetY),
  206. transformationMatrix,
  207. window.devicePixelRatio
  208. );
  209. if (!clickedNode) {
  210. return;
  211. }
  212. drawOverlay(transformationMatrix, clickedNode?.rect ?? null, null);
  213. onNodeSelect(clickedNode?.node);
  214. }
  215. isDragging = false;
  216. };
  217. const handleZoom =
  218. (direction: 'in' | 'out', scalingFactor: number = 1.1, zoomOrigin?: vec2) =>
  219. () => {
  220. const newScale = direction === 'in' ? scalingFactor : 1 / scalingFactor;
  221. // Generate a scaling matrix that also accounts for the zoom origin
  222. // so when the scale is applied, the zoom origin stays in the same place
  223. // i.e. cursor position or center of the canvas
  224. const center = vec2.fromValues(canvasSize.width / 2, canvasSize.height / 2);
  225. const origin = zoomOrigin ?? center;
  226. const scaleMatrix = getCenterScaleMatrixFromConfigPosition(
  227. vec2.fromValues(newScale, newScale),
  228. origin
  229. );
  230. mat3.multiply(currTransformationMatrix, scaleMatrix, currTransformationMatrix);
  231. drawViewHierarchy(currTransformationMatrix);
  232. drawOverlay(currTransformationMatrix, selectedRect, hoveredRect);
  233. mat3.copy(transformationMatrix, currTransformationMatrix);
  234. };
  235. const handleWheel = (e: WheelEvent) => {
  236. if (e.ctrlKey || e.metaKey) {
  237. e.preventDefault();
  238. handleZoom(e.deltaY > 0 ? 'out' : 'in', 1.05, lastMousePosition)();
  239. }
  240. };
  241. const options: AddEventListenerOptions & EventListenerOptions = {passive: true};
  242. const onwheelOptions: AddEventListenerOptions & EventListenerOptions = {
  243. passive: false,
  244. };
  245. overlayRef.addEventListener('mousedown', handleMouseDown, options);
  246. overlayRef.addEventListener('mousemove', handleMouseMove, options);
  247. overlayRef.addEventListener('mouseup', handleMouseUp, options);
  248. overlayRef.addEventListener('click', handleMouseClick, options);
  249. zoomIn.addEventListener('click', handleZoom('in'), options);
  250. zoomOut.addEventListener('click', handleZoom('out'), options);
  251. overlayRef.addEventListener('wheel', handleWheel, onwheelOptions);
  252. drawViewHierarchy(transformationMatrix);
  253. drawOverlay(transformationMatrix, selectedRect, hoveredRect);
  254. return () => {
  255. overlayRef.removeEventListener('mousedown', handleMouseDown, options);
  256. overlayRef.removeEventListener('mousemove', handleMouseMove, options);
  257. overlayRef.removeEventListener('mouseup', handleMouseUp, options);
  258. overlayRef.removeEventListener('click', handleMouseClick, options);
  259. zoomIn.removeEventListener('click', handleZoom('in'), options);
  260. zoomOut.removeEventListener('click', handleZoom('out'), options);
  261. overlayRef.removeEventListener('wheel', handleWheel, onwheelOptions);
  262. };
  263. }, [
  264. transformationMatrix,
  265. canvasRef,
  266. scale,
  267. overlayRef,
  268. hierarchyData.nodes,
  269. onNodeSelect,
  270. drawViewHierarchy,
  271. drawOverlay,
  272. selectedNode,
  273. nodeLookupMap,
  274. zoomIn,
  275. zoomOut,
  276. canvasSize.width,
  277. canvasSize.height,
  278. ]);
  279. return (
  280. <Stack>
  281. <InteractionContainer>
  282. <Controls>
  283. <Button size="xs" ref={setZoomIn} aria-label={t('Zoom In on wireframe')}>
  284. <IconAdd size="xs" />
  285. </Button>
  286. <Button size="xs" ref={setZoomOut} aria-label={t('Zoom Out on wireframe')}>
  287. <IconSubtract size="xs" />
  288. </Button>
  289. </Controls>
  290. <InteractionOverlayCanvas
  291. data-test-id="view-hierarchy-wireframe-overlay"
  292. ref={r => setOverlayRef(r)}
  293. />
  294. </InteractionContainer>
  295. <WireframeCanvas
  296. data-test-id="view-hierarchy-wireframe"
  297. ref={r => setCanvasRef(r)}
  298. />
  299. </Stack>
  300. );
  301. }
  302. export {Wireframe};
  303. const Stack = styled('div')`
  304. position: relative;
  305. height: 100%;
  306. width: 100%;
  307. `;
  308. const InteractionContainer = styled('div')`
  309. position: absolute;
  310. top: 0;
  311. left: 0;
  312. height: 100%;
  313. width: 100%;
  314. cursor: pointer;
  315. `;
  316. const Controls = styled('div')`
  317. position: absolute;
  318. top: ${space(2)};
  319. right: ${space(2)};
  320. display: flex;
  321. flex-direction: column;
  322. gap: ${space(0.5)};
  323. `;
  324. const InteractionOverlayCanvas = styled('canvas')`
  325. width: 100%;
  326. height: 100%;
  327. `;
  328. const WireframeCanvas = styled('canvas')`
  329. background-color: ${p => p.theme.surface100};
  330. width: 100%;
  331. height: 100%;
  332. `;