flamegraphZoomViewMinimap.tsx 15 KB

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