virtualizedViewManager.tsx 15 KB

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