mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
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;
|
|
}
|
|
}
|