virtualizedViewManager.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. import clamp from 'sentry/utils/number/clamp';
  2. import type {TraceTreeNode} from 'sentry/views/performance/newTraceDetails/traceTree';
  3. const DIVIDER_WIDTH = 6;
  4. type ViewColumn = {
  5. column_refs: (HTMLElement | undefined)[];
  6. translate: [number, number];
  7. width: number;
  8. };
  9. type Matrix2D = [number, number, number, number, number, number];
  10. /**
  11. * Tracks the state of the virtualized view and manages the resizing of the columns.
  12. * Children components should call `registerColumnRef` and `registerDividerRef` to register
  13. * their respective refs.
  14. */
  15. export class VirtualizedViewManager {
  16. width: number = 0;
  17. container: HTMLElement | null = null;
  18. dividerRef: HTMLElement | null = null;
  19. resizeObserver: ResizeObserver | null = null;
  20. dividerStartVec: [number, number] | null = null;
  21. measurer: RowMeasurer = new RowMeasurer();
  22. spanDrawMatrix: Matrix2D = [1, 0, 0, 1, 0, 0];
  23. spanScalingFactor: number = 1;
  24. minSpanScalingFactor: number = 0.02;
  25. spanSpace: [number, number] = [0, 1000];
  26. spanView: [number, number] = [0, 1000];
  27. columns: {
  28. list: ViewColumn;
  29. span_list: ViewColumn;
  30. };
  31. span_bars: ({ref: HTMLElement; space: [number, number]} | undefined)[] = [];
  32. constructor(columns: {
  33. list: Omit<ViewColumn, 'translate'>;
  34. span_list: Omit<ViewColumn, 'translate'>;
  35. }) {
  36. this.columns = {
  37. list: {...columns.list, translate: [0, 0]},
  38. span_list: {...columns.span_list, translate: [0, 0]},
  39. };
  40. this.onSyncedScrollbarScroll = this.onSyncedScrollbarScroll.bind(this);
  41. this.onDividerMouseDown = this.onDividerMouseDown.bind(this);
  42. this.onDividerMouseUp = this.onDividerMouseUp.bind(this);
  43. this.onDividerMouseMove = this.onDividerMouseMove.bind(this);
  44. }
  45. onContainerRef(container: HTMLElement | null) {
  46. if (container) {
  47. this.initialize(container);
  48. } else {
  49. this.teardown();
  50. }
  51. }
  52. registerDividerRef(ref: HTMLElement | null) {
  53. if (!ref) {
  54. if (this.dividerRef) {
  55. this.dividerRef.removeEventListener('mousedown', this.onDividerMouseDown);
  56. }
  57. this.dividerRef = null;
  58. return;
  59. }
  60. this.dividerRef = ref;
  61. this.dividerRef.style.width = `${DIVIDER_WIDTH}px`;
  62. this.dividerRef.style.transform = `translateX(${
  63. this.width * (this.columns.list.width - (2 * DIVIDER_WIDTH) / this.width)
  64. }px)`;
  65. ref.addEventListener('mousedown', this.onDividerMouseDown, {passive: true});
  66. }
  67. onDividerMouseDown(event: MouseEvent) {
  68. if (!this.container) {
  69. return;
  70. }
  71. this.dividerStartVec = [event.clientX, event.clientY];
  72. this.container.style.userSelect = 'none';
  73. this.container.addEventListener('mouseup', this.onDividerMouseUp, {passive: true});
  74. this.container.addEventListener('mousemove', this.onDividerMouseMove, {
  75. passive: true,
  76. });
  77. }
  78. onDividerMouseUp(event: MouseEvent) {
  79. if (!this.container || !this.dividerStartVec) {
  80. return;
  81. }
  82. const distance = event.clientX - this.dividerStartVec[0];
  83. const distancePercentage = distance / this.width;
  84. this.columns.list.width = this.columns.list.width + distancePercentage;
  85. this.columns.span_list.width = this.columns.span_list.width - distancePercentage;
  86. this.container.style.userSelect = 'auto';
  87. this.dividerStartVec = null;
  88. this.container.removeEventListener('mouseup', this.onDividerMouseUp);
  89. this.container.removeEventListener('mousemove', this.onDividerMouseMove);
  90. }
  91. onDividerMouseMove(event: MouseEvent) {
  92. if (!this.dividerStartVec || !this.dividerRef) {
  93. return;
  94. }
  95. const distance = event.clientX - this.dividerStartVec[0];
  96. const distancePercentage = distance / this.width;
  97. this.computeSpanDrawMatrix(
  98. this.width,
  99. this.columns.span_list.width - distancePercentage
  100. );
  101. this.dividerRef.style.transform = `translateX(${
  102. this.width * (this.columns.list.width + distancePercentage) - DIVIDER_WIDTH / 2
  103. }px)`;
  104. const listWidth = this.columns.list.width * 100 + distancePercentage * 100 + '%';
  105. const spanWidth = this.columns.span_list.width * 100 - distancePercentage * 100 + '%';
  106. for (let i = 0; i < this.columns.list.column_refs.length; i++) {
  107. const list = this.columns.list.column_refs[i];
  108. if (list) {
  109. list.style.width = listWidth;
  110. }
  111. const span = this.columns.span_list.column_refs[i];
  112. if (span) {
  113. span.style.width = spanWidth;
  114. }
  115. const span_bar = this.span_bars[i];
  116. if (span_bar) {
  117. span_bar.ref.style.transform = `matrix(${this.computeSpanMatrixTransform(
  118. span_bar.space
  119. ).join(',')}`;
  120. }
  121. }
  122. }
  123. registerSpanBarRef(ref: HTMLElement | null, space: [number, number], index: number) {
  124. this.span_bars[index] = ref ? {ref, space} : undefined;
  125. }
  126. registerColumnRef(
  127. column: string,
  128. ref: HTMLElement | null,
  129. index: number,
  130. node: TraceTreeNode<any>
  131. ) {
  132. if (!this.columns[column]) {
  133. throw new TypeError('Invalid column');
  134. }
  135. if (column === 'list') {
  136. const element = this.columns[column].column_refs[index];
  137. if (ref === undefined && element) {
  138. element.removeEventListener('wheel', this.onSyncedScrollbarScroll);
  139. } else if (ref) {
  140. const scrollableElement = ref.children[0];
  141. if (scrollableElement) {
  142. this.measurer.measure(node, scrollableElement as HTMLElement);
  143. ref.addEventListener('wheel', this.onSyncedScrollbarScroll, {passive: true});
  144. }
  145. }
  146. }
  147. this.columns[column].column_refs[index] = ref ?? undefined;
  148. }
  149. scrollSyncRaf: number | null = null;
  150. onSyncedScrollbarScroll(event: WheelEvent) {
  151. const columnWidth = this.columns.list.width * this.width;
  152. this.columns.list.translate[0] = clamp(
  153. this.columns.list.translate[0] - event.deltaX,
  154. -(this.measurer.max - columnWidth + 16), // 16px margin so we dont scroll right to the last px
  155. 0
  156. );
  157. for (let i = 0; i < this.columns.list.column_refs.length; i++) {
  158. const list = this.columns.list.column_refs[i];
  159. if (list?.children?.[0]) {
  160. (list.children[0] as HTMLElement).style.transform =
  161. `translateX(${this.columns.list.translate[0]}px)`;
  162. }
  163. }
  164. // Eventually sync the column translation to the container
  165. if (this.scrollSyncRaf) {
  166. window.cancelAnimationFrame(this.scrollSyncRaf);
  167. }
  168. this.scrollSyncRaf = window.requestAnimationFrame(() => {
  169. // @TODO if user is outside of the container, scroll the container to the left
  170. this.container?.style.setProperty(
  171. '--column-translate-x',
  172. this.columns.list.translate[0] + 'px'
  173. );
  174. });
  175. }
  176. initialize(container: HTMLElement) {
  177. this.teardown();
  178. this.container = container;
  179. this.container.addEventListener('wheel', this.onPreventBackForwardNavigation, {
  180. passive: false,
  181. });
  182. this.resizeObserver = new ResizeObserver(entries => {
  183. const entry = entries[0];
  184. if (!entry) {
  185. throw new Error('ResizeObserver entry is undefined');
  186. }
  187. this.width = entry.contentRect.width;
  188. this.computeSpanDrawMatrix(this.width, this.columns.span_list.width);
  189. if (this.dividerRef) {
  190. this.dividerRef.style.transform = `translateX(${
  191. this.width * this.columns.list.width - DIVIDER_WIDTH / 2
  192. }px)`;
  193. }
  194. });
  195. this.resizeObserver.observe(container);
  196. }
  197. onPreventBackForwardNavigation(event: WheelEvent) {
  198. if (event.deltaX !== 0) {
  199. event.preventDefault();
  200. }
  201. }
  202. initializeSpanSpace(spanSpace: [number, number], spanView?: [number, number]) {
  203. this.spanSpace = [...spanSpace];
  204. this.spanView = spanView ?? [...spanSpace];
  205. this.computeSpanDrawMatrix(this.width, this.columns.span_list.width);
  206. }
  207. computeSpanDrawMatrix(width: number, span_column_width: number): Matrix2D {
  208. // https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix
  209. // biome-ignore format: off
  210. const mat3: Matrix2D = [
  211. 1, 0, 0,
  212. 1, 0, 0,
  213. ];
  214. if (this.spanSpace[1] === 0 || this.spanView[1] === 0) {
  215. return mat3;
  216. }
  217. const spanColumnWidth = width * span_column_width;
  218. const viewToSpace = this.spanSpace[1] / this.spanView[1];
  219. const physicalToView = spanColumnWidth / this.spanView[1];
  220. mat3[0] = viewToSpace * physicalToView;
  221. this.spanScalingFactor = viewToSpace;
  222. this.minSpanScalingFactor = window.devicePixelRatio / this.width;
  223. this.spanDrawMatrix = mat3;
  224. return mat3;
  225. }
  226. computeSpanTextPlacement(
  227. translateX: number,
  228. span_space: [number, number]
  229. ): 'left' | 'right' | 'inside left' {
  230. // | <--> | |
  231. // | | <--> |
  232. // | <--------> |
  233. // | | |
  234. // | | |
  235. const half = (this.columns.span_list.width * this.width) / 2;
  236. const spanWidth = span_space[1] * this.spanDrawMatrix[0];
  237. if (translateX > half) {
  238. return 'left';
  239. }
  240. if (spanWidth > half) {
  241. return 'inside left';
  242. }
  243. return 'right';
  244. }
  245. inverseSpanScaling(span_space: [number, number]): number {
  246. return 1 / this.computeSpanMatrixTransform(span_space)[0];
  247. }
  248. computeSpanMatrixTransform(span_space: [number, number]): Matrix2D {
  249. const scale = Math.max(
  250. this.minSpanScalingFactor,
  251. (span_space[1] / this.spanView[1]) * this.spanScalingFactor
  252. );
  253. const x = span_space[0] - this.spanView[0];
  254. const translateInPixels = x * this.spanDrawMatrix[0];
  255. return [scale, 0, 0, 1, translateInPixels, 0];
  256. }
  257. draw() {}
  258. teardown() {
  259. if (this.container) {
  260. this.container.removeEventListener('wheel', this.onPreventBackForwardNavigation);
  261. }
  262. if (this.resizeObserver) {
  263. this.resizeObserver.disconnect();
  264. }
  265. }
  266. }
  267. class RowMeasurer {
  268. cache: Map<TraceTreeNode<any>, number> = new Map();
  269. elements: HTMLElement[] = [];
  270. measureQueue: [TraceTreeNode<any>, HTMLElement][] = [];
  271. drainRaf: number | null = null;
  272. max: number = 0;
  273. enqueueMeasure(node: TraceTreeNode<any>, element: HTMLElement) {
  274. if (this.cache.has(node)) {
  275. return;
  276. }
  277. this.measureQueue.push([node, element]);
  278. if (this.drainRaf !== null) {
  279. window.cancelAnimationFrame(this.drainRaf);
  280. }
  281. this.drainRaf = window.requestAnimationFrame(() => {
  282. this.drain();
  283. });
  284. }
  285. drain() {
  286. for (const [node, element] of this.measureQueue) {
  287. this.measure(node, element);
  288. }
  289. }
  290. measure(node: TraceTreeNode<any>, element: HTMLElement): number {
  291. const cache = this.cache.get(node);
  292. if (cache !== undefined) {
  293. return cache;
  294. }
  295. const width = element.getBoundingClientRect().width;
  296. if (width > this.max) {
  297. this.max = width;
  298. }
  299. this.cache.set(node, width);
  300. return width;
  301. }
  302. }