wireframe.tsx 12 KB

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