virtualizedViewManager.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. import type {List} from 'react-virtualized';
  2. import clamp from 'sentry/utils/number/clamp';
  3. import type {
  4. TraceTree,
  5. TraceTreeNode,
  6. } from 'sentry/views/performance/newTraceDetails/traceTree';
  7. const DIVIDER_WIDTH = 6;
  8. type ViewColumn = {
  9. column_nodes: TraceTreeNode<TraceTree.NodeValue>[];
  10. column_refs: (HTMLElement | undefined)[];
  11. translate: [number, number];
  12. width: number;
  13. };
  14. type Matrix2D = [number, number, number, number, number, number];
  15. /**
  16. * Tracks the state of the virtualized view and manages the resizing of the columns.
  17. * Children components should call `registerColumnRef` and `registerDividerRef` to register
  18. * their respective refs.
  19. */
  20. export class VirtualizedViewManager {
  21. width: number = 0;
  22. virtualizedList: List | null = null;
  23. container: HTMLElement | null = null;
  24. dividerRef: HTMLElement | null = null;
  25. resizeObserver: ResizeObserver | null = null;
  26. dividerStartVec: [number, number] | null = null;
  27. measurer: RowMeasurer = new RowMeasurer();
  28. spanDrawMatrix: Matrix2D = [1, 0, 0, 1, 0, 0];
  29. spanScalingFactor: number = 1;
  30. minSpanScalingFactor: number = 0.02;
  31. space: [number, number] = [0, 1000];
  32. view: [number, number] = [0, 1000];
  33. columns: {
  34. list: ViewColumn;
  35. span_list: ViewColumn;
  36. };
  37. span_bars: ({ref: HTMLElement; space: [number, number]} | undefined)[] = [];
  38. constructor(columns: {
  39. list: Pick<ViewColumn, 'width'>;
  40. span_list: Pick<ViewColumn, 'width'>;
  41. }) {
  42. this.columns = {
  43. list: {...columns.list, column_nodes: [], column_refs: [], translate: [0, 0]},
  44. span_list: {
  45. ...columns.span_list,
  46. column_nodes: [],
  47. column_refs: [],
  48. translate: [0, 0],
  49. },
  50. };
  51. this.onSyncedScrollbarScroll = this.onSyncedScrollbarScroll.bind(this);
  52. this.onDividerMouseDown = this.onDividerMouseDown.bind(this);
  53. this.onDividerMouseUp = this.onDividerMouseUp.bind(this);
  54. this.onDividerMouseMove = this.onDividerMouseMove.bind(this);
  55. }
  56. onContainerRef(container: HTMLElement | null) {
  57. if (container) {
  58. this.initialize(container);
  59. } else {
  60. this.teardown();
  61. }
  62. }
  63. registerVirtualizedList(list: List | null) {
  64. this.virtualizedList = list;
  65. }
  66. registerDividerRef(ref: HTMLElement | null) {
  67. if (!ref) {
  68. if (this.dividerRef) {
  69. this.dividerRef.removeEventListener('mousedown', this.onDividerMouseDown);
  70. }
  71. this.dividerRef = null;
  72. return;
  73. }
  74. this.dividerRef = ref;
  75. this.dividerRef.style.width = `${DIVIDER_WIDTH}px`;
  76. this.dividerRef.style.transform = `translateX(${
  77. this.width * (this.columns.list.width - (2 * DIVIDER_WIDTH) / this.width)
  78. }px)`;
  79. ref.addEventListener('mousedown', this.onDividerMouseDown, {passive: true});
  80. }
  81. onDividerMouseDown(event: MouseEvent) {
  82. if (!this.container) {
  83. return;
  84. }
  85. this.dividerStartVec = [event.clientX, event.clientY];
  86. this.container.style.userSelect = 'none';
  87. this.container.addEventListener('mouseup', this.onDividerMouseUp, {passive: true});
  88. this.container.addEventListener('mousemove', this.onDividerMouseMove, {
  89. passive: true,
  90. });
  91. }
  92. onDividerMouseUp(event: MouseEvent) {
  93. if (!this.container || !this.dividerStartVec) {
  94. return;
  95. }
  96. const distance = event.clientX - this.dividerStartVec[0];
  97. const distancePercentage = distance / this.width;
  98. this.columns.list.width = this.columns.list.width + distancePercentage;
  99. this.columns.span_list.width = this.columns.span_list.width - distancePercentage;
  100. this.container.style.userSelect = 'auto';
  101. this.dividerStartVec = null;
  102. this.container.removeEventListener('mouseup', this.onDividerMouseUp);
  103. this.container.removeEventListener('mousemove', this.onDividerMouseMove);
  104. }
  105. onDividerMouseMove(event: MouseEvent) {
  106. if (!this.dividerStartVec || !this.dividerRef) {
  107. return;
  108. }
  109. const distance = event.clientX - this.dividerStartVec[0];
  110. const distancePercentage = distance / this.width;
  111. this.computeSpanDrawMatrix(
  112. this.width,
  113. this.columns.span_list.width - distancePercentage
  114. );
  115. this.dividerRef.style.transform = `translateX(${
  116. this.width * (this.columns.list.width + distancePercentage) - DIVIDER_WIDTH / 2
  117. }px)`;
  118. const listWidth = this.columns.list.width * 100 + distancePercentage * 100 + '%';
  119. const spanWidth = this.columns.span_list.width * 100 - distancePercentage * 100 + '%';
  120. for (let i = 0; i < this.columns.list.column_refs.length; i++) {
  121. const list = this.columns.list.column_refs[i];
  122. if (list) {
  123. list.style.width = listWidth;
  124. }
  125. const span = this.columns.span_list.column_refs[i];
  126. if (span) {
  127. span.style.width = spanWidth;
  128. }
  129. const span_bar = this.span_bars[i];
  130. if (span_bar) {
  131. span_bar.ref.style.transform = `matrix(${this.computeSpanMatrixTransform(
  132. span_bar.space
  133. ).join(',')}`;
  134. }
  135. }
  136. }
  137. registerSpanBarRef(ref: HTMLElement | null, space: [number, number], index: number) {
  138. this.span_bars[index] = ref ? {ref, space} : undefined;
  139. }
  140. registerColumnRef(
  141. column: string,
  142. ref: HTMLElement | null,
  143. index: number,
  144. node: TraceTreeNode<any>
  145. ) {
  146. if (!this.columns[column]) {
  147. throw new TypeError('Invalid column');
  148. }
  149. if (typeof index !== 'number' || isNaN(index)) {
  150. throw new TypeError('Invalid index');
  151. }
  152. if (column === 'list') {
  153. const element = this.columns[column].column_refs[index];
  154. if (ref === undefined && element) {
  155. element.removeEventListener('wheel', this.onSyncedScrollbarScroll);
  156. } else if (ref) {
  157. const scrollableElement = ref.children[0];
  158. if (scrollableElement) {
  159. this.measurer.measure(node, scrollableElement as HTMLElement);
  160. ref.addEventListener('wheel', this.onSyncedScrollbarScroll, {passive: true});
  161. }
  162. }
  163. }
  164. this.columns[column].column_refs[index] = ref ?? undefined;
  165. this.columns[column].column_nodes[index] = node ?? undefined;
  166. }
  167. scrollSyncRaf: number | null = null;
  168. onSyncedScrollbarScroll(event: WheelEvent) {
  169. if (this.bringRowIntoViewAnimation !== null) {
  170. window.cancelAnimationFrame(this.bringRowIntoViewAnimation);
  171. this.bringRowIntoViewAnimation = null;
  172. }
  173. this.enqueueOnScrollEndOutOfBoundsCheck();
  174. const columnWidth = this.columns.list.width * this.width;
  175. this.columns.list.translate[0] = clamp(
  176. this.columns.list.translate[0] - event.deltaX,
  177. -(this.measurer.max - columnWidth + 16), // 16px margin so we dont scroll right to the last px
  178. 0
  179. );
  180. for (let i = 0; i < this.columns.list.column_refs.length; i++) {
  181. const list = this.columns.list.column_refs[i];
  182. if (list?.children?.[0]) {
  183. (list.children[0] as HTMLElement).style.transform =
  184. `translateX(${this.columns.list.translate[0]}px)`;
  185. }
  186. }
  187. // Eventually sync the column translation to the container
  188. if (this.scrollSyncRaf) {
  189. window.cancelAnimationFrame(this.scrollSyncRaf);
  190. }
  191. this.scrollSyncRaf = window.requestAnimationFrame(() => {
  192. // @TODO if user is outside of the container, scroll the container to the left
  193. this.container?.style.setProperty(
  194. '--column-translate-x',
  195. this.columns.list.translate[0] + 'px'
  196. );
  197. });
  198. }
  199. scrollEndSyncRaf: number | null = null;
  200. enqueueOnScrollEndOutOfBoundsCheck() {
  201. if (this.scrollEndSyncRaf !== null) {
  202. window.cancelAnimationFrame(this.scrollEndSyncRaf);
  203. }
  204. const start = performance.now();
  205. const rafCallback = (now: number) => {
  206. const elapsed = now - start;
  207. if (elapsed > 300) {
  208. this.onScrollEndOutOfBoundsCheck();
  209. } else {
  210. this.scrollEndSyncRaf = window.requestAnimationFrame(rafCallback);
  211. }
  212. };
  213. this.scrollEndSyncRaf = window.requestAnimationFrame(rafCallback);
  214. }
  215. onScrollEndOutOfBoundsCheck() {
  216. this.scrollEndSyncRaf = null;
  217. const translation = this.columns.list.translate[0];
  218. let min = Number.POSITIVE_INFINITY;
  219. let max = Number.NEGATIVE_INFINITY;
  220. let innerMostNode: TraceTreeNode<any> | undefined;
  221. const offset = this.virtualizedList?.Grid?.props.overscanRowCount ?? 0;
  222. const renderCount = this.columns.span_list.column_refs.length;
  223. for (let i = offset + 1; i < renderCount - offset; i++) {
  224. const width = this.measurer.cache.get(this.columns.list.column_nodes[i]);
  225. if (width === undefined) {
  226. // this is unlikely to happen, but we should trigger a sync measure event if it does
  227. continue;
  228. }
  229. min = Math.min(min, width);
  230. max = Math.max(max, width);
  231. innerMostNode =
  232. !innerMostNode || this.columns.list.column_nodes[i].depth < innerMostNode.depth
  233. ? this.columns.list.column_nodes[i]
  234. : innerMostNode;
  235. }
  236. // Scroll to half row so as to indicate other child/parent links
  237. const VISUAL_OFFSET = 24 / 2;
  238. if (innerMostNode) {
  239. if (translation + max < 0) {
  240. const target = Math.min(-innerMostNode.depth * 24 + VISUAL_OFFSET, 0);
  241. this.animateScrollColumnTo(target);
  242. } else if (
  243. translation + innerMostNode.depth * 24 >
  244. this.columns.list.width * this.width
  245. ) {
  246. const target = Math.min(-innerMostNode.depth * 24 + VISUAL_OFFSET, 0);
  247. this.animateScrollColumnTo(target);
  248. }
  249. }
  250. }
  251. bringRowIntoViewAnimation: number | null = null;
  252. animateScrollColumnTo(x: number) {
  253. const start = performance.now();
  254. const duration = 600;
  255. const startPosition = this.columns.list.translate[0];
  256. const distance = x - startPosition;
  257. const animate = (now: number) => {
  258. const elapsed = now - start;
  259. const progress = elapsed / duration;
  260. const eased = easeOutQuad(progress);
  261. const pos = startPosition + distance * eased;
  262. for (let i = 0; i < this.columns.list.column_refs.length; i++) {
  263. const list = this.columns.list.column_refs[i];
  264. if (list?.children?.[0]) {
  265. (list.children[0] as HTMLElement).style.transform = `translateX(${pos}px)`;
  266. }
  267. }
  268. if (progress < 1) {
  269. this.columns.list.translate[0] = pos;
  270. this.bringRowIntoViewAnimation = window.requestAnimationFrame(animate);
  271. } else {
  272. this.columns.list.translate[0] = x;
  273. }
  274. };
  275. this.bringRowIntoViewAnimation = window.requestAnimationFrame(animate);
  276. }
  277. initialize(container: HTMLElement) {
  278. this.teardown();
  279. this.container = container;
  280. this.container.addEventListener('wheel', this.onPreventBackForwardNavigation, {
  281. passive: false,
  282. });
  283. this.resizeObserver = new ResizeObserver(entries => {
  284. const entry = entries[0];
  285. if (!entry) {
  286. throw new Error('ResizeObserver entry is undefined');
  287. }
  288. this.width = entry.contentRect.width;
  289. this.computeSpanDrawMatrix(this.width, this.columns.span_list.width);
  290. if (this.dividerRef) {
  291. this.dividerRef.style.transform = `translateX(${
  292. this.width * this.columns.list.width - DIVIDER_WIDTH / 2
  293. }px)`;
  294. }
  295. });
  296. this.resizeObserver.observe(container);
  297. }
  298. onPreventBackForwardNavigation(event: WheelEvent) {
  299. if (event.deltaX !== 0) {
  300. event.preventDefault();
  301. }
  302. }
  303. initializeSpace(space: [number, number], view?: [number, number]) {
  304. this.space = [...space];
  305. this.view = view ?? [...space];
  306. this.computeSpanDrawMatrix(this.width, this.columns.span_list.width);
  307. }
  308. computeSpanDrawMatrix(width: number, span_column_width: number): Matrix2D {
  309. // https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix
  310. // biome-ignore format: off
  311. const mat3: Matrix2D = [1, 0, 0, 1, 0, 0];
  312. if (this.space[1] === 0 || this.view[1] === 0) {
  313. return mat3;
  314. }
  315. const spanColumnWidth = width * span_column_width;
  316. const viewToSpace = this.space[1] / this.view[1];
  317. const physicalToView = spanColumnWidth / this.view[1];
  318. mat3[0] = viewToSpace * physicalToView;
  319. this.spanScalingFactor = viewToSpace;
  320. this.minSpanScalingFactor = window.devicePixelRatio / this.width;
  321. this.spanDrawMatrix = mat3;
  322. return mat3;
  323. }
  324. computeSpanTextPlacement(
  325. translateX: number,
  326. span_space: [number, number]
  327. ): 'left' | 'right' | 'inside left' {
  328. // | <--> | |
  329. // | | <--> |
  330. // | <--------> |
  331. // | | |
  332. // | | |
  333. const half = (this.columns.span_list.width * this.width) / 2;
  334. const spanWidth = span_space[1] * this.spanDrawMatrix[0];
  335. if (translateX > half) {
  336. return 'left';
  337. }
  338. if (spanWidth > half) {
  339. return 'inside left';
  340. }
  341. return 'right';
  342. }
  343. inverseSpanScaling(span_space: [number, number]): number {
  344. return 1 / this.computeSpanMatrixTransform(span_space)[0];
  345. }
  346. computeSpanMatrixTransform(span_space: [number, number]): Matrix2D {
  347. const scale = Math.max(
  348. this.minSpanScalingFactor,
  349. (span_space[1] / this.view[1]) * this.spanScalingFactor
  350. );
  351. const x = span_space[0] - this.view[0];
  352. const translateInPixels = x * this.spanDrawMatrix[0];
  353. return [scale, 0, 0, 1, translateInPixels, 0];
  354. }
  355. teardown() {
  356. if (this.container) {
  357. this.container.removeEventListener('wheel', this.onPreventBackForwardNavigation);
  358. }
  359. if (this.resizeObserver) {
  360. this.resizeObserver.disconnect();
  361. }
  362. }
  363. }
  364. function easeOutQuad(x: number): number {
  365. return 1 - (1 - x) * (1 - x);
  366. }
  367. class RowMeasurer {
  368. cache: Map<TraceTreeNode<any>, number> = new Map();
  369. elements: HTMLElement[] = [];
  370. measureQueue: [TraceTreeNode<any>, HTMLElement][] = [];
  371. drainRaf: number | null = null;
  372. max: number = 0;
  373. constructor() {
  374. this.drain = this.drain.bind(this);
  375. }
  376. enqueueMeasure(node: TraceTreeNode<any>, element: HTMLElement) {
  377. if (this.cache.has(node)) {
  378. return;
  379. }
  380. this.measureQueue.push([node, element]);
  381. if (this.drainRaf !== null) {
  382. window.cancelAnimationFrame(this.drainRaf);
  383. }
  384. this.drainRaf = window.requestAnimationFrame(this.drain);
  385. }
  386. drain() {
  387. for (const [node, element] of this.measureQueue) {
  388. this.measure(node, element);
  389. }
  390. }
  391. measure(node: TraceTreeNode<any>, element: HTMLElement): number {
  392. const cache = this.cache.get(node);
  393. if (cache !== undefined) {
  394. return cache;
  395. }
  396. const width = element.getBoundingClientRect().width;
  397. if (width > this.max) {
  398. this.max = width;
  399. }
  400. this.cache.set(node, width);
  401. return width;
  402. }
  403. }