wireframe.tsx 11 KB

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