123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372 |
- import clamp from 'sentry/utils/number/clamp';
- import type {TraceTreeNode} from 'sentry/views/performance/newTraceDetails/traceTree';
- const DIVIDER_WIDTH = 6;
- type ViewColumn = {
- column_refs: (HTMLElement | undefined)[];
- translate: [number, number];
- width: number;
- };
- type Matrix2D = [number, number, number, number, number, number];
- /**
- * Tracks the state of the virtualized view and manages the resizing of the columns.
- * Children components should call `registerColumnRef` and `registerDividerRef` to register
- * their respective refs.
- */
- export class VirtualizedViewManager {
- width: number = 0;
- container: HTMLElement | null = null;
- dividerRef: HTMLElement | null = null;
- resizeObserver: ResizeObserver | null = null;
- dividerStartVec: [number, number] | null = null;
- measurer: RowMeasurer = new RowMeasurer();
- spanDrawMatrix: Matrix2D = [1, 0, 0, 1, 0, 0];
- spanScalingFactor: number = 1;
- minSpanScalingFactor: number = 0.02;
- spanSpace: [number, number] = [0, 1000];
- spanView: [number, number] = [0, 1000];
- columns: {
- list: ViewColumn;
- span_list: ViewColumn;
- };
- span_bars: ({ref: HTMLElement; space: [number, number]} | undefined)[] = [];
- constructor(columns: {
- list: Omit<ViewColumn, 'translate'>;
- span_list: Omit<ViewColumn, 'translate'>;
- }) {
- this.columns = {
- list: {...columns.list, translate: [0, 0]},
- span_list: {...columns.span_list, translate: [0, 0]},
- };
- this.onSyncedScrollbarScroll = this.onSyncedScrollbarScroll.bind(this);
- this.onDividerMouseDown = this.onDividerMouseDown.bind(this);
- this.onDividerMouseUp = this.onDividerMouseUp.bind(this);
- this.onDividerMouseMove = this.onDividerMouseMove.bind(this);
- }
- onContainerRef(container: HTMLElement | null) {
- if (container) {
- this.initialize(container);
- } else {
- this.teardown();
- }
- }
- registerDividerRef(ref: HTMLElement | null) {
- if (!ref) {
- if (this.dividerRef) {
- this.dividerRef.removeEventListener('mousedown', this.onDividerMouseDown);
- }
- this.dividerRef = null;
- return;
- }
- this.dividerRef = ref;
- this.dividerRef.style.width = `${DIVIDER_WIDTH}px`;
- this.dividerRef.style.transform = `translateX(${
- this.width * (this.columns.list.width - (2 * DIVIDER_WIDTH) / this.width)
- }px)`;
- ref.addEventListener('mousedown', this.onDividerMouseDown, {passive: true});
- }
- onDividerMouseDown(event: MouseEvent) {
- if (!this.container) {
- return;
- }
- this.dividerStartVec = [event.clientX, event.clientY];
- this.container.style.userSelect = 'none';
- this.container.addEventListener('mouseup', this.onDividerMouseUp, {passive: true});
- this.container.addEventListener('mousemove', this.onDividerMouseMove, {
- passive: true,
- });
- }
- onDividerMouseUp(event: MouseEvent) {
- if (!this.container || !this.dividerStartVec) {
- return;
- }
- const distance = event.clientX - this.dividerStartVec[0];
- const distancePercentage = distance / this.width;
- this.columns.list.width = this.columns.list.width + distancePercentage;
- this.columns.span_list.width = this.columns.span_list.width - distancePercentage;
- this.container.style.userSelect = 'auto';
- this.dividerStartVec = null;
- this.container.removeEventListener('mouseup', this.onDividerMouseUp);
- this.container.removeEventListener('mousemove', this.onDividerMouseMove);
- }
- onDividerMouseMove(event: MouseEvent) {
- if (!this.dividerStartVec || !this.dividerRef) {
- return;
- }
- const distance = event.clientX - this.dividerStartVec[0];
- const distancePercentage = distance / this.width;
- this.computeSpanDrawMatrix(
- this.width,
- this.columns.span_list.width - distancePercentage
- );
- this.dividerRef.style.transform = `translateX(${
- this.width * (this.columns.list.width + distancePercentage) - DIVIDER_WIDTH / 2
- }px)`;
- const listWidth = this.columns.list.width * 100 + distancePercentage * 100 + '%';
- const spanWidth = this.columns.span_list.width * 100 - distancePercentage * 100 + '%';
- for (let i = 0; i < this.columns.list.column_refs.length; i++) {
- const list = this.columns.list.column_refs[i];
- if (list) {
- list.style.width = listWidth;
- }
- const span = this.columns.span_list.column_refs[i];
- if (span) {
- span.style.width = spanWidth;
- }
- const span_bar = this.span_bars[i];
- if (span_bar) {
- span_bar.ref.style.transform = `matrix(${this.computeSpanMatrixTransform(
- span_bar.space
- ).join(',')}`;
- }
- }
- }
- registerSpanBarRef(ref: HTMLElement | null, space: [number, number], index: number) {
- this.span_bars[index] = ref ? {ref, space} : undefined;
- }
- registerColumnRef(
- column: string,
- ref: HTMLElement | null,
- index: number,
- node: TraceTreeNode<any>
- ) {
- if (!this.columns[column]) {
- throw new TypeError('Invalid column');
- }
- if (column === 'list') {
- const element = this.columns[column].column_refs[index];
- if (ref === undefined && element) {
- element.removeEventListener('wheel', this.onSyncedScrollbarScroll);
- } else if (ref) {
- const scrollableElement = ref.children[0];
- if (scrollableElement) {
- this.measurer.measure(node, scrollableElement as HTMLElement);
- ref.addEventListener('wheel', this.onSyncedScrollbarScroll, {passive: true});
- }
- }
- }
- this.columns[column].column_refs[index] = ref ?? undefined;
- }
- scrollSyncRaf: number | null = null;
- onSyncedScrollbarScroll(event: WheelEvent) {
- const columnWidth = this.columns.list.width * this.width;
- this.columns.list.translate[0] = clamp(
- this.columns.list.translate[0] - event.deltaX,
- -(this.measurer.max - columnWidth + 16), // 16px margin so we dont scroll right to the last px
- 0
- );
- for (let i = 0; i < this.columns.list.column_refs.length; i++) {
- const list = this.columns.list.column_refs[i];
- if (list?.children?.[0]) {
- (list.children[0] as HTMLElement).style.transform =
- `translateX(${this.columns.list.translate[0]}px)`;
- }
- }
- // Eventually sync the column translation to the container
- if (this.scrollSyncRaf) {
- window.cancelAnimationFrame(this.scrollSyncRaf);
- }
- this.scrollSyncRaf = window.requestAnimationFrame(() => {
- // @TODO if user is outside of the container, scroll the container to the left
- this.container?.style.setProperty(
- '--column-translate-x',
- this.columns.list.translate[0] + 'px'
- );
- });
- }
- initialize(container: HTMLElement) {
- this.teardown();
- this.container = container;
- this.container.addEventListener('wheel', this.onPreventBackForwardNavigation, {
- passive: false,
- });
- this.resizeObserver = new ResizeObserver(entries => {
- const entry = entries[0];
- if (!entry) {
- throw new Error('ResizeObserver entry is undefined');
- }
- this.width = entry.contentRect.width;
- this.computeSpanDrawMatrix(this.width, this.columns.span_list.width);
- if (this.dividerRef) {
- this.dividerRef.style.transform = `translateX(${
- this.width * this.columns.list.width - DIVIDER_WIDTH / 2
- }px)`;
- }
- });
- this.resizeObserver.observe(container);
- }
- onPreventBackForwardNavigation(event: WheelEvent) {
- if (event.deltaX !== 0) {
- event.preventDefault();
- }
- }
- initializeSpanSpace(spanSpace: [number, number], spanView?: [number, number]) {
- this.spanSpace = [...spanSpace];
- this.spanView = spanView ?? [...spanSpace];
- this.computeSpanDrawMatrix(this.width, this.columns.span_list.width);
- }
- computeSpanDrawMatrix(width: number, span_column_width: number): Matrix2D {
- // https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix
- // biome-ignore format: off
- const mat3: Matrix2D = [
- 1, 0, 0,
- 1, 0, 0,
- ];
- if (this.spanSpace[1] === 0 || this.spanView[1] === 0) {
- return mat3;
- }
- const spanColumnWidth = width * span_column_width;
- const viewToSpace = this.spanSpace[1] / this.spanView[1];
- const physicalToView = spanColumnWidth / this.spanView[1];
- mat3[0] = viewToSpace * physicalToView;
- this.spanScalingFactor = viewToSpace;
- this.minSpanScalingFactor = window.devicePixelRatio / this.width;
- this.spanDrawMatrix = mat3;
- return mat3;
- }
- computeSpanTextPlacement(
- translateX: number,
- span_space: [number, number]
- ): 'left' | 'right' | 'inside left' {
- // | <--> | |
- // | | <--> |
- // | <--------> |
- // | | |
- // | | |
- const half = (this.columns.span_list.width * this.width) / 2;
- const spanWidth = span_space[1] * this.spanDrawMatrix[0];
- if (translateX > half) {
- return 'left';
- }
- if (spanWidth > half) {
- return 'inside left';
- }
- return 'right';
- }
- inverseSpanScaling(span_space: [number, number]): number {
- return 1 / this.computeSpanMatrixTransform(span_space)[0];
- }
- computeSpanMatrixTransform(span_space: [number, number]): Matrix2D {
- const scale = Math.max(
- this.minSpanScalingFactor,
- (span_space[1] / this.spanView[1]) * this.spanScalingFactor
- );
- const x = span_space[0] - this.spanView[0];
- const translateInPixels = x * this.spanDrawMatrix[0];
- return [scale, 0, 0, 1, translateInPixels, 0];
- }
- draw() {}
- teardown() {
- if (this.container) {
- this.container.removeEventListener('wheel', this.onPreventBackForwardNavigation);
- }
- if (this.resizeObserver) {
- this.resizeObserver.disconnect();
- }
- }
- }
- class RowMeasurer {
- cache: Map<TraceTreeNode<any>, number> = new Map();
- elements: HTMLElement[] = [];
- measureQueue: [TraceTreeNode<any>, HTMLElement][] = [];
- drainRaf: number | null = null;
- max: number = 0;
- enqueueMeasure(node: TraceTreeNode<any>, element: HTMLElement) {
- if (this.cache.has(node)) {
- return;
- }
- this.measureQueue.push([node, element]);
- if (this.drainRaf !== null) {
- window.cancelAnimationFrame(this.drainRaf);
- }
- this.drainRaf = window.requestAnimationFrame(() => {
- this.drain();
- });
- }
- drain() {
- for (const [node, element] of this.measureQueue) {
- this.measure(node, element);
- }
- }
- measure(node: TraceTreeNode<any>, element: HTMLElement): number {
- const cache = this.cache.get(node);
- if (cache !== undefined) {
- return cache;
- }
- const width = element.getBoundingClientRect().width;
- if (width > this.max) {
- this.max = width;
- }
- this.cache.set(node, width);
- return width;
- }
- }
|