flamegraphZoomViewMinimap.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. import {Fragment, useCallback, useEffect, useMemo, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {mat3, vec2} from 'gl-matrix';
  4. import {CanvasPoolManager, CanvasScheduler} from 'sentry/utils/profiling/canvasScheduler';
  5. import {Flamegraph} from 'sentry/utils/profiling/flamegraph';
  6. import {useDispatchFlamegraphState} from 'sentry/utils/profiling/flamegraph/hooks/useFlamegraphState';
  7. import {useFlamegraphTheme} from 'sentry/utils/profiling/flamegraph/useFlamegraphTheme';
  8. import {FlamegraphCanvas} from 'sentry/utils/profiling/flamegraphCanvas';
  9. import {FlamegraphView} from 'sentry/utils/profiling/flamegraphView';
  10. import {Rect} from 'sentry/utils/profiling/gl/utils';
  11. import {FlamegraphRenderer} from 'sentry/utils/profiling/renderers/flamegraphRenderer';
  12. import {PositionIndicatorRenderer} from 'sentry/utils/profiling/renderers/positionIndicatorRenderer';
  13. import usePrevious from 'sentry/utils/usePrevious';
  14. interface FlamegraphZoomViewMinimapProps {
  15. canvasPoolManager: CanvasPoolManager;
  16. flamegraph: Flamegraph;
  17. flamegraphMiniMapCanvas: FlamegraphCanvas | null;
  18. flamegraphMiniMapCanvasRef: HTMLCanvasElement | null;
  19. flamegraphMiniMapOverlayCanvasRef: HTMLCanvasElement | null;
  20. flamegraphMiniMapView: FlamegraphView | null;
  21. setFlamegraphMiniMapCanvasRef: React.Dispatch<
  22. React.SetStateAction<HTMLCanvasElement | null>
  23. >;
  24. setFlamegraphMiniMapOverlayCanvasRef: React.Dispatch<
  25. React.SetStateAction<HTMLCanvasElement | null>
  26. >;
  27. }
  28. function FlamegraphZoomViewMinimap({
  29. canvasPoolManager,
  30. flamegraph,
  31. flamegraphMiniMapCanvas,
  32. flamegraphMiniMapCanvasRef,
  33. flamegraphMiniMapOverlayCanvasRef,
  34. flamegraphMiniMapView,
  35. setFlamegraphMiniMapCanvasRef,
  36. setFlamegraphMiniMapOverlayCanvasRef,
  37. }: FlamegraphZoomViewMinimapProps): React.ReactElement {
  38. const flamegraphTheme = useFlamegraphTheme();
  39. const [lastInteraction, setLastInteraction] = useState<
  40. 'pan' | 'click' | 'zoom' | 'scroll' | 'select' | null
  41. >(null);
  42. const dispatch = useDispatchFlamegraphState();
  43. const [configSpaceCursor, setConfigSpaceCursor] = useState<vec2 | null>(null);
  44. const scheduler = useMemo(() => new CanvasScheduler(), []);
  45. const flamegraphMiniMapRenderer = useMemo(() => {
  46. if (!flamegraphMiniMapCanvasRef) {
  47. return null;
  48. }
  49. const BAR_HEIGHT =
  50. flamegraphTheme.SIZES.MINIMAP_HEIGHT /
  51. (flamegraph.depth + flamegraphTheme.SIZES.FLAMEGRAPH_DEPTH_OFFSET);
  52. return new FlamegraphRenderer(flamegraphMiniMapCanvasRef, flamegraph, {
  53. ...flamegraphTheme,
  54. SIZES: {
  55. ...flamegraphTheme.SIZES,
  56. BAR_HEIGHT,
  57. },
  58. });
  59. }, [flamegraph, flamegraphMiniMapCanvasRef, flamegraphTheme]);
  60. const positionIndicatorRenderer: PositionIndicatorRenderer | null = useMemo(() => {
  61. if (!flamegraphMiniMapOverlayCanvasRef) {
  62. return null;
  63. }
  64. return new PositionIndicatorRenderer(
  65. flamegraphMiniMapOverlayCanvasRef,
  66. flamegraphTheme
  67. );
  68. }, [flamegraphMiniMapOverlayCanvasRef, flamegraphTheme]);
  69. useEffect(() => {
  70. if (
  71. !flamegraphMiniMapCanvas ||
  72. !flamegraphMiniMapView ||
  73. !flamegraphMiniMapRenderer
  74. ) {
  75. return undefined;
  76. }
  77. const drawRectangles = () => {
  78. flamegraphMiniMapRenderer.draw(
  79. flamegraphMiniMapView.fromConfigSpace(flamegraphMiniMapCanvas.physicalSpace),
  80. new Map()
  81. );
  82. };
  83. scheduler.registerBeforeFrameCallback(drawRectangles);
  84. return () => {
  85. scheduler.unregisterBeforeFrameCallback(drawRectangles);
  86. };
  87. }, [
  88. flamegraphMiniMapCanvas,
  89. flamegraphMiniMapRenderer,
  90. scheduler,
  91. flamegraphMiniMapView,
  92. ]);
  93. useEffect(() => {
  94. if (
  95. !flamegraphMiniMapCanvas ||
  96. !flamegraphMiniMapView ||
  97. !positionIndicatorRenderer
  98. ) {
  99. return undefined;
  100. }
  101. const clearOverlayCanvas = () => {
  102. positionIndicatorRenderer.context.clearRect(
  103. 0,
  104. 0,
  105. positionIndicatorRenderer.canvas.width,
  106. positionIndicatorRenderer.canvas.height
  107. );
  108. };
  109. const drawPosition = () => {
  110. positionIndicatorRenderer.draw(
  111. flamegraphMiniMapView.configView,
  112. flamegraphMiniMapView.configSpace,
  113. flamegraphMiniMapView.fromConfigSpace(flamegraphMiniMapCanvas.physicalSpace)
  114. );
  115. };
  116. scheduler.registerBeforeFrameCallback(clearOverlayCanvas);
  117. scheduler.registerAfterFrameCallback(drawPosition);
  118. scheduler.draw();
  119. return () => {
  120. scheduler.unregisterBeforeFrameCallback(clearOverlayCanvas);
  121. scheduler.unregisterAfterFrameCallback(drawPosition);
  122. };
  123. }, [
  124. flamegraphMiniMapCanvas,
  125. flamegraphMiniMapView,
  126. scheduler,
  127. positionIndicatorRenderer,
  128. ]);
  129. const previousInteraction = usePrevious(lastInteraction);
  130. const beforeInteractionConfigView = useRef<Rect | null>(null);
  131. useEffect(() => {
  132. if (!flamegraphMiniMapView) {
  133. return;
  134. }
  135. // Check if we are starting a new interaction
  136. if (previousInteraction === null && lastInteraction) {
  137. beforeInteractionConfigView.current = flamegraphMiniMapView.configView.clone();
  138. return;
  139. }
  140. if (
  141. beforeInteractionConfigView.current &&
  142. !beforeInteractionConfigView.current.equals(flamegraphMiniMapView.configView)
  143. ) {
  144. dispatch({
  145. type: 'checkpoint',
  146. payload: flamegraphMiniMapView.configView.clone(),
  147. });
  148. }
  149. }, [lastInteraction, flamegraphMiniMapView, dispatch, previousInteraction]);
  150. const [startDragVector, setStartDragConfigSpaceCursor] = useState<vec2 | null>(null);
  151. const [lastDragVector, setLastDragVector] = useState<vec2 | null>(null);
  152. useEffect(() => {
  153. canvasPoolManager.registerScheduler(scheduler);
  154. return () => canvasPoolManager.unregisterScheduler(scheduler);
  155. }, [scheduler, canvasPoolManager]);
  156. const onMouseDrag = useCallback(
  157. (evt: React.MouseEvent<HTMLCanvasElement>) => {
  158. if (!lastDragVector || !flamegraphMiniMapCanvas || !flamegraphMiniMapView) {
  159. return;
  160. }
  161. const logicalMousePos = vec2.fromValues(
  162. evt.nativeEvent.offsetX,
  163. evt.nativeEvent.offsetY
  164. );
  165. const physicalMousePos = vec2.scale(
  166. vec2.create(),
  167. logicalMousePos,
  168. window.devicePixelRatio
  169. );
  170. const physicalDelta = vec2.subtract(
  171. vec2.create(),
  172. physicalMousePos,
  173. lastDragVector
  174. );
  175. if (physicalDelta[0] === 0 && physicalDelta[1] === 0) {
  176. return;
  177. }
  178. const physicalToConfig = mat3.invert(
  179. mat3.create(),
  180. flamegraphMiniMapView.fromConfigSpace(flamegraphMiniMapCanvas.physicalSpace)
  181. );
  182. const configDelta = vec2.transformMat3(
  183. vec2.create(),
  184. physicalDelta,
  185. physicalToConfig
  186. );
  187. canvasPoolManager.dispatch('transform config view', [
  188. mat3.fromTranslation(mat3.create(), configDelta),
  189. ]);
  190. setLastDragVector(physicalMousePos);
  191. },
  192. [flamegraphMiniMapCanvas, flamegraphMiniMapView, lastDragVector, canvasPoolManager]
  193. );
  194. const onMinimapCanvasMouseMove = useCallback(
  195. (evt: React.MouseEvent<HTMLCanvasElement>) => {
  196. if (!flamegraphMiniMapCanvas || !flamegraphMiniMapView) {
  197. return;
  198. }
  199. const configSpaceMouse = flamegraphMiniMapView.getConfigSpaceCursor(
  200. vec2.fromValues(evt.nativeEvent.offsetX, evt.nativeEvent.offsetY),
  201. flamegraphMiniMapCanvas
  202. );
  203. setConfigSpaceCursor(configSpaceMouse);
  204. if (lastDragVector) {
  205. onMouseDrag(evt);
  206. setLastInteraction('pan');
  207. return;
  208. }
  209. if (startDragVector) {
  210. const start = vec2.min(vec2.create(), startDragVector, configSpaceMouse);
  211. const end = vec2.max(vec2.create(), startDragVector, configSpaceMouse);
  212. const rect = new Rect(
  213. start[0],
  214. configSpaceMouse[1] - flamegraphMiniMapView.configView.height / 2,
  215. end[0] - start[0],
  216. flamegraphMiniMapView.configView.height
  217. );
  218. canvasPoolManager.dispatch('set config view', [rect]);
  219. setLastInteraction('select');
  220. return;
  221. }
  222. setLastInteraction(null);
  223. },
  224. [
  225. flamegraphMiniMapCanvas,
  226. flamegraphMiniMapView,
  227. canvasPoolManager,
  228. lastDragVector,
  229. onMouseDrag,
  230. startDragVector,
  231. ]
  232. );
  233. const onMinimapScroll = useCallback(
  234. (evt: WheelEvent) => {
  235. if (!flamegraphMiniMapCanvas || !flamegraphMiniMapView) {
  236. return;
  237. }
  238. {
  239. const physicalDelta = vec2.fromValues(evt.deltaX * 0.8, evt.deltaY);
  240. const physicalToConfig = mat3.invert(
  241. mat3.create(),
  242. flamegraphMiniMapView.fromConfigView(flamegraphMiniMapCanvas.physicalSpace)
  243. );
  244. const [m00, m01, m02, m10, m11, m12] = physicalToConfig;
  245. const configDelta = vec2.transformMat3(vec2.create(), physicalDelta, [
  246. m00,
  247. m01,
  248. m02,
  249. m10,
  250. m11,
  251. m12,
  252. 0,
  253. 0,
  254. 0,
  255. ]);
  256. const translate = mat3.fromTranslation(mat3.create(), configDelta);
  257. canvasPoolManager.dispatch('transform config view', [translate]);
  258. }
  259. },
  260. [flamegraphMiniMapCanvas, flamegraphMiniMapView, canvasPoolManager]
  261. );
  262. const onMinimapZoom = useCallback(
  263. (evt: WheelEvent) => {
  264. if (!flamegraphMiniMapCanvas || !flamegraphMiniMapView) {
  265. return;
  266. }
  267. const identity = mat3.identity(mat3.create());
  268. const scale = 1 - evt.deltaY * 0.001 * -1; // -1 to invert scale
  269. const mouseInConfigSpace = flamegraphMiniMapView.getConfigSpaceCursor(
  270. vec2.fromValues(evt.offsetX, evt.offsetY),
  271. flamegraphMiniMapCanvas
  272. );
  273. const configCenter = vec2.fromValues(
  274. mouseInConfigSpace[0],
  275. flamegraphMiniMapView.configView.y
  276. );
  277. const invertedConfigCenter = vec2.multiply(
  278. vec2.create(),
  279. vec2.fromValues(-1, -1),
  280. configCenter
  281. );
  282. const translated = mat3.translate(mat3.create(), identity, configCenter);
  283. const scaled = mat3.scale(mat3.create(), translated, vec2.fromValues(scale, 1));
  284. const translatedBack = mat3.translate(mat3.create(), scaled, invertedConfigCenter);
  285. canvasPoolManager.dispatch('transform config view', [translatedBack]);
  286. },
  287. [flamegraphMiniMapCanvas, flamegraphMiniMapView, canvasPoolManager]
  288. );
  289. const onMinimapCanvasMouseDown = useCallback(
  290. evt => {
  291. if (
  292. !configSpaceCursor ||
  293. !flamegraphMiniMapCanvas ||
  294. !flamegraphMiniMapView ||
  295. !canvasPoolManager
  296. ) {
  297. return;
  298. }
  299. const logicalMousePos = vec2.fromValues(
  300. evt.nativeEvent.offsetX,
  301. evt.nativeEvent.offsetY
  302. );
  303. const physicalMousePos = vec2.scale(
  304. vec2.create(),
  305. logicalMousePos,
  306. window.devicePixelRatio
  307. );
  308. if (flamegraphMiniMapView.configView.contains(configSpaceCursor)) {
  309. setLastDragVector(physicalMousePos);
  310. } else {
  311. const startConfigSpaceCursor = flamegraphMiniMapView.getConfigSpaceCursor(
  312. vec2.fromValues(evt.nativeEvent.offsetX, evt.nativeEvent.offsetY),
  313. flamegraphMiniMapCanvas
  314. );
  315. setStartDragConfigSpaceCursor(startConfigSpaceCursor);
  316. }
  317. setLastInteraction('select');
  318. },
  319. [configSpaceCursor, flamegraphMiniMapCanvas, flamegraphMiniMapView, canvasPoolManager]
  320. );
  321. const onMinimapCanvasMouseUp = useCallback(() => {
  322. setConfigSpaceCursor(null);
  323. setStartDragConfigSpaceCursor(null);
  324. setLastDragVector(null);
  325. setLastInteraction(null);
  326. }, []);
  327. useEffect(() => {
  328. if (!flamegraphMiniMapCanvasRef) {
  329. return undefined;
  330. }
  331. let wheelStopTimeoutId: number | undefined;
  332. function onCanvasWheel(evt: WheelEvent) {
  333. window.clearTimeout(wheelStopTimeoutId);
  334. wheelStopTimeoutId = window.setTimeout(() => {
  335. setLastInteraction(null);
  336. }, 300);
  337. evt.preventDefault();
  338. // When we zoom, we want to clear cursor so that any tooltips
  339. // rendered on the flamegraph are removed from the view
  340. setConfigSpaceCursor(null);
  341. if (evt.metaKey || evt.ctrlKey) {
  342. onMinimapZoom(evt);
  343. setLastInteraction('zoom');
  344. } else {
  345. onMinimapScroll(evt);
  346. setLastInteraction('scroll');
  347. }
  348. }
  349. flamegraphMiniMapCanvasRef.addEventListener('wheel', onCanvasWheel);
  350. return () => {
  351. window.clearTimeout(wheelStopTimeoutId);
  352. flamegraphMiniMapCanvasRef.removeEventListener('wheel', onCanvasWheel);
  353. };
  354. }, [flamegraphMiniMapCanvasRef, onMinimapZoom, onMinimapScroll]);
  355. useEffect(() => {
  356. window.addEventListener('mouseup', onMinimapCanvasMouseUp);
  357. return () => {
  358. window.removeEventListener('mouseup', onMinimapCanvasMouseUp);
  359. };
  360. }, [onMinimapCanvasMouseUp]);
  361. useEffect(() => {
  362. if (!flamegraphMiniMapCanvasRef) {
  363. return undefined;
  364. }
  365. const onCanvasWheel = (evt: WheelEvent) => {
  366. evt.preventDefault();
  367. const isZoom = evt.metaKey;
  368. // @TODO figure out what key to use for other platforms
  369. if (isZoom) {
  370. onMinimapZoom(evt);
  371. } else {
  372. onMinimapScroll(evt);
  373. }
  374. };
  375. flamegraphMiniMapCanvasRef.addEventListener('wheel', onCanvasWheel);
  376. return () => flamegraphMiniMapCanvasRef?.removeEventListener('wheel', onCanvasWheel);
  377. }, [flamegraphMiniMapCanvasRef, onMinimapScroll, onMinimapZoom]);
  378. return (
  379. <Fragment>
  380. <Canvas
  381. ref={c => setFlamegraphMiniMapCanvasRef(c)}
  382. onMouseDown={onMinimapCanvasMouseDown}
  383. onMouseMove={onMinimapCanvasMouseMove}
  384. onMouseLeave={onMinimapCanvasMouseUp}
  385. cursor={
  386. configSpaceCursor &&
  387. flamegraphMiniMapView?.configView.contains(configSpaceCursor)
  388. ? 'grab'
  389. : 'col-resize'
  390. }
  391. />
  392. <OverlayCanvas ref={c => setFlamegraphMiniMapOverlayCanvasRef(c)} />
  393. </Fragment>
  394. );
  395. }
  396. const Canvas = styled('canvas')<{cursor?: React.CSSProperties['cursor']}>`
  397. width: 100%;
  398. height: 100%;
  399. position: absolute;
  400. left: 0;
  401. top: 0;
  402. cursor: ${props => props.cursor ?? 'default'};
  403. user-select: none;
  404. `;
  405. const OverlayCanvas = styled(Canvas)`
  406. pointer-events: none;
  407. `;
  408. export {FlamegraphZoomViewMinimap};