Files
2024-02-15 17:01:50 +00:00

859 lines
24 KiB
TypeScript

import {useLayoutEffect, useState} from 'react';
import type Fuse from 'fuse.js';
import {mat3, vec2} from 'gl-matrix';
import type {CanvasPoolManager} from 'sentry/utils/profiling/canvasScheduler';
import type {CanvasView} from 'sentry/utils/profiling/canvasView';
import {clamp, colorComponentsToRGBA} from 'sentry/utils/profiling/colors/utils';
import type {ColorChannels} from 'sentry/utils/profiling/flamegraph/flamegraphTheme';
import type {FlamegraphCanvas} from 'sentry/utils/profiling/flamegraphCanvas';
import type {FlamegraphFrame} from 'sentry/utils/profiling/flamegraphFrame';
import type {
FlamegraphRenderer,
FlamegraphRendererConstructor,
} from 'sentry/utils/profiling/renderers/flamegraphRenderer';
import type {SpanChartRenderer2D} from 'sentry/utils/profiling/renderers/spansRenderer';
import type {
UIFramesRenderer,
UIFramesRendererConstructor,
} from 'sentry/utils/profiling/renderers/UIFramesRenderer';
import type {SpanChartNode} from 'sentry/utils/profiling/spanChart';
import {Rect} from 'sentry/utils/profiling/speedscope';
export function initializeFlamegraphRenderer(
renderers: FlamegraphRendererConstructor[],
constructorArgs: ConstructorParameters<FlamegraphRendererConstructor>
): FlamegraphRenderer | null;
export function initializeFlamegraphRenderer(
renderers: UIFramesRendererConstructor[],
constructorArgs: ConstructorParameters<UIFramesRendererConstructor>
): UIFramesRenderer | null;
export function initializeFlamegraphRenderer(
renderers: FlamegraphRendererConstructor[] | UIFramesRendererConstructor[],
constructorArgs:
| ConstructorParameters<FlamegraphRendererConstructor>
| ConstructorParameters<UIFramesRendererConstructor>
): FlamegraphRenderer | UIFramesRenderer | null {
for (const renderer of renderers) {
let r: FlamegraphRenderer | UIFramesRenderer | null = null;
try {
// @ts-expect-error ts complains that constructor args are not of tuple
// type, even though they are.
r = new renderer(...constructorArgs);
// eslint-disable-next-line no-empty
} catch (e) {}
// A renderer should only fail if the rendering context was unavailable
if (r && r.ctx !== null) {
return r;
}
}
return null;
}
export function createShader(
gl: WebGLRenderingContext,
type: WebGLRenderingContext['VERTEX_SHADER'] | WebGLRenderingContext['FRAGMENT_SHADER'],
source: string
): WebGLShader {
const shader = gl.createShader(type);
if (!shader) {
throw new Error('Could not create shader');
}
gl.shaderSource(shader, source);
gl.compileShader(shader);
const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
if (success) {
return shader;
}
gl.deleteShader(shader);
throw new Error(`Failed to compile ${type} shader`);
}
export function createProgram(
gl: WebGLRenderingContext,
vertexShader: WebGLShader,
fragmentShader: WebGLShader
): WebGLProgram {
const program = gl.createProgram();
if (!program) {
throw new Error('Could not create program');
}
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
const success = gl.getProgramParameter(program, gl.LINK_STATUS);
if (success) {
return program;
}
gl.deleteProgram(program);
throw new Error('Failed to create program');
}
export function getUniform(
gl: WebGLRenderingContext,
program: WebGLProgram,
name: string
): WebGLUniformLocation {
const uniform = gl.getUniformLocation(program, name);
if (!uniform) {
throw new Error(`Could not locate uniform ${name} in shader`);
}
return uniform;
}
export function getAttribute(
gl: WebGLRenderingContext,
program: WebGLProgram,
name: string
): number {
const attribute = gl.getAttribLocation(program, name);
if (attribute === -1) {
throw new Error(`Could not locate attribute ${name} in shader`);
}
return attribute;
}
export function createAndBindBuffer(
gl: WebGLRenderingContext,
data: ArrayBufferView,
usage: number
): WebGLBuffer {
const buffer = gl.createBuffer();
if (!buffer) {
throw new Error('Could not create buffer');
}
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, data, usage);
return buffer;
}
export function pointToAndEnableVertexAttribute(
gl: WebGLRenderingContext,
attribute: number,
attributeInfo: {
normalized: boolean;
offset: number;
size: number;
stride: number;
type: number;
}
) {
gl.vertexAttribPointer(
attribute,
attributeInfo.size,
attributeInfo.type,
attributeInfo.normalized,
attributeInfo.stride,
attributeInfo.offset
);
gl.enableVertexAttribArray(attribute);
}
// Create a projection matrix with origins at 0,0 in top left corner, scaled to width/height
export function makeProjectionMatrix(width: number, height: number): mat3 {
const projectionMatrix = mat3.create();
mat3.translate(projectionMatrix, projectionMatrix, vec2.fromValues(-1, 1));
mat3.scale(
projectionMatrix,
projectionMatrix,
vec2.divide(vec2.create(), vec2.fromValues(2, -2), vec2.fromValues(width, height))
);
return projectionMatrix;
}
const canvasToDisplaySizeMap = new Map<HTMLCanvasElement, [number, number]>();
function onResize(entries: ResizeObserverEntry[]) {
for (const entry of entries) {
let width;
let height;
let dpr = window.devicePixelRatio;
if (entry.devicePixelContentBoxSize) {
// NOTE: Only this path gives the correct answer
// The other paths are imperfect fallbacks
// for browsers that don't provide anyway to do this
width = entry.devicePixelContentBoxSize[0].inlineSize;
height = entry.devicePixelContentBoxSize[0].blockSize;
dpr = 1; // it's already in width and height
} else if (entry.contentBoxSize) {
if (entry.contentBoxSize[0]) {
width = entry.contentBoxSize[0].inlineSize;
height = entry.contentBoxSize[0].blockSize;
} else {
// @ts-expect-error
width = entry.contentBoxSize.inlineSize;
// @ts-expect-error
height = entry.contentBoxSize.blockSize;
}
} else {
width = entry.contentRect.width;
height = entry.contentRect.height;
}
const displayWidth = Math.round(width * dpr);
const displayHeight = Math.round(height * dpr);
canvasToDisplaySizeMap.set(entry.target as HTMLCanvasElement, [
displayWidth,
displayHeight,
]);
resizeCanvasToDisplaySize(entry.target as HTMLCanvasElement);
}
}
export const watchForResize = (
canvas: HTMLCanvasElement[],
callback?: (entries: ResizeObserverEntry[], observer: ResizeObserver) => void
): ResizeObserver => {
const handler: ResizeObserverCallback = (entries, observer) => {
onResize(entries);
callback?.(entries, observer);
};
for (const c of canvas) {
canvasToDisplaySizeMap.set(c, [c.width, c.height]);
}
const resizeObserver = new ResizeObserver(handler);
try {
// only call us of the number of device pixels changed
canvas.forEach(c => {
resizeObserver.observe(c, {box: 'device-pixel-content-box'});
});
} catch (ex) {
// device-pixel-content-box is not supported so fallback to this
canvas.forEach(c => {
resizeObserver.observe(c, {box: 'content-box'});
});
}
return resizeObserver;
};
export function resizeCanvasToDisplaySize(canvas: HTMLCanvasElement): boolean {
// Get the size the browser is displaying the canvas in device pixels.
const size = canvasToDisplaySizeMap.get(canvas);
if (!size) {
const displayWidth = canvas.clientWidth * window.devicePixelRatio;
const displayHeight = canvas.clientHeight * window.devicePixelRatio;
canvas.width = displayWidth;
canvas.height = displayHeight;
return false;
}
const [displayWidth, displayHeight] = size;
// Check if the canvas is not the same size.
const needResize = canvas.width !== displayWidth || canvas.height !== displayHeight;
if (needResize) {
// Make the canvas the same size
canvas.width = displayWidth;
canvas.height = displayHeight;
}
return needResize;
}
export function transformMatrixBetweenRect(from: Rect, to: Rect): mat3 {
return mat3.fromValues(
to.width / from.width,
0,
0,
0,
to.height / from.height,
0,
to.x - from.x * (to.width / from.width),
to.y - from.y * (to.height / from.height),
1
);
}
export function getContext(
canvas: HTMLCanvasElement,
context: '2d'
): CanvasRenderingContext2D;
export function getContext(
canvas: HTMLCanvasElement,
context: 'webgl'
): WebGLRenderingContext;
export function getContext(canvas: HTMLCanvasElement, context: string): RenderingContext {
const ctx =
context === 'webgl'
? canvas.getContext(context, {antialias: false})
: canvas.getContext(context);
if (!ctx) {
throw new Error(`Could not get context ${context}`);
}
return ctx;
}
export function safeGetContext(
canvas: HTMLCanvasElement,
context: '2d'
): CanvasRenderingContext2D;
export function safeGetContext(
canvas: HTMLCanvasElement,
context: 'webgl'
): WebGLRenderingContext;
export function safeGetContext(
canvas: HTMLCanvasElement,
context: string
): RenderingContext | null {
const ctx =
context === 'webgl'
? canvas.getContext(context, {antialias: false})
: canvas.getContext(context);
return ctx;
}
export const ELLIPSIS = '\u2026';
export function measureText(string: string, ctx?: CanvasRenderingContext2D): Rect {
if (!string) {
return Rect.Empty();
}
const context = ctx || getContext(document.createElement('canvas'), '2d');
const measures = context.measureText(string);
return new Rect(
0,
0,
measures.width,
// https://stackoverflow.com/questions/1134586/how-can-you-find-the-height-of-text-on-an-html-canvas
measures.actualBoundingBoxAscent + measures.actualBoundingBoxDescent
);
}
/**
* Returns first index of value in array where value.start < target
* Example: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], target = 5, returns 4 which points to value 3
* @param target {number}
* @param values {Array<T> | ReadonlyArray<T>}
* @returns number
*/
export function upperBound<T extends {end: number; start: number}>(
target: number,
values: Array<T> | ReadonlyArray<T>
): number;
export function upperBound<T>(
target: number,
values: Array<T> | ReadonlyArray<T>,
getValue: (value: T) => number
): number;
export function upperBound<T extends {end: number; start: number} | {x: number}>(
target: number,
values: Array<T> | ReadonlyArray<T> | Record<any, any>,
getValue?: (value: T) => number
) {
let low = 0;
let high = values.length;
if (high === 0) {
return 0;
}
if (high === 1) {
return getValue
? getValue(values[0]) < target
? 1
: 0
: values[0].start < target
? 1
: 0;
}
while (low !== high) {
const mid = low + Math.floor((high - low) / 2);
const value = getValue ? getValue(values[mid]) : values[mid].start;
if (value < target) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
}
/**
* Returns first index of value in array where value.end < target
* Example: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], target = 5, returns 3 which points to value 4
* @param target {number}
* @param values {Array<T> | ReadonlyArray<T>}
* @returns number
*/
export function lowerBound<T extends {end: number; start: number}>(
target: number,
values: Array<T> | ReadonlyArray<T>
): number;
export function lowerBound<T>(
target: number,
values: Array<T> | ReadonlyArray<T>,
getValue: (value: T) => number
): number;
export function lowerBound<T extends {end: number; start: number}>(
target: number,
values: Array<T> | ReadonlyArray<T>,
getValue?: (value: T) => number
): number {
let low = 0;
let high = values.length;
if (high === 0) {
return 0;
}
if (high === 1) {
return getValue
? getValue(values[0]) < target
? 1
: 0
: values[0].end < target
? 1
: 0;
}
while (low !== high) {
const mid = low + Math.floor((high - low) / 2);
const value = getValue ? getValue(values[mid]) : values[mid].end;
if (value < target) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
}
export function formatColorForSpan(
frame: SpanChartNode,
renderer: SpanChartRenderer2D
): string {
const color = renderer.getColorForFrame(frame);
if (Array.isArray(color)) {
return colorComponentsToRGBA(color);
}
return '';
}
export function formatColorForFrame(frame: FlamegraphFrame, color: ColorChannels): string;
export function formatColorForFrame(
frame: FlamegraphFrame,
color: FlamegraphRenderer
): string;
export function formatColorForFrame(
frame: FlamegraphFrame,
rendererOrColor: FlamegraphRenderer | ColorChannels
): string {
if (Array.isArray(rendererOrColor)) {
return colorComponentsToRGBA(rendererOrColor);
}
const color = rendererOrColor.getColorForFrame(frame);
if (color.length === 4) {
return `rgba(${color
.slice(0, 3)
.map(n => n * 255)
.join(',')}, ${color[3]})`;
}
return `rgba(${color.map(n => n * 255).join(',')}, 1.0)`;
}
export interface TrimTextCenter {
end: number;
length: number;
start: number;
text: string;
}
export function hexToColorChannels(color: string, alpha: number): ColorChannels {
return [
parseInt(color.slice(1, 3), 16) / 255,
parseInt(color.slice(3, 5), 16) / 255,
parseInt(color.slice(5, 7), 16) / 255,
alpha,
];
}
// Utility function to compute a clamped view. This is essentially a bounds check
// to ensure that zoomed viewports stays in the bounds and does not escape the view.
export function computeClampedConfigView(
newConfigView: Rect,
{width, height}: {height: {max: number; min: number}; width: {max: number; min: number}}
) {
if (!newConfigView.isValid()) {
throw new Error(newConfigView.toString());
}
const clampedWidth = clamp(newConfigView.width, width.min, width.max);
const clampedHeight = clamp(newConfigView.height, height.min, height.max);
const maxX = width.max - clampedWidth;
const maxY = Math.max(height.max - clampedHeight, 0);
const clampedX = clamp(newConfigView.x, 0, maxX);
const clampedY = clamp(newConfigView.y, 0, maxY);
return new Rect(clampedX, clampedY, clampedWidth, clampedHeight);
}
/**
* computeHighlightedBounds determines if a supplied boundary should be reduced in size
* or shifted based on the results of a trim operation
*/
export function computeHighlightedBounds(
bounds: Fuse.RangeTuple,
trim: TrimTextCenter
): Fuse.RangeTuple {
if (!trim.length) {
return bounds;
}
const isStartBetweenTrim = bounds[0] >= trim.start && bounds[0] <= trim.end;
const isEndBetweenTrim = bounds[1] >= trim.start && bounds[1] <= trim.end;
const isFullyTruncated = isStartBetweenTrim && isEndBetweenTrim;
// example:
// -[UIScrollView _smoothScrollDisplayLink:]
// "smooth" in "-[UIScrollView _…ScrollDisplayLink:]"
// ^^
if (isFullyTruncated) {
return [trim.start, trim.start + 1];
}
if (bounds[0] < trim.start) {
// "ScrollView" in '-[UIScrollView _sm…rollDisplayLink:]'
// ^--------^
if (bounds[1] < trim.start) {
return [bounds[0], bounds[1]];
}
// "smoothScroll" in -[UIScrollView _smooth…DisplayLink:]'
// ^-----^
if (isEndBetweenTrim) {
return [bounds[0], trim.start + 1];
}
// "smoothScroll" in -[UIScrollView _sm…llDisplayLink:]'
// ^---^
if (bounds[1] > trim.end) {
return [bounds[0], bounds[1] - trim.length + 1];
}
}
// "smoothScroll" in -[UIScrollView _…scrollDisplayLink:]'
// ^-----^
if (isStartBetweenTrim && bounds[1] > trim.end) {
return [trim.start, bounds[1] - trim.length + 1];
}
// "display" in -[UIScrollView _…scrollDisplayLink:]'
// ^-----^
if (bounds[0] > trim.end) {
return [bounds[0] - trim.length + 1, bounds[1] - trim.length + 1];
}
throw new Error(`Unhandled case: ${JSON.stringify(bounds)} ${trim}`);
}
// Utility function to allow zooming into frames using a specific strategy. Supports
// min zooming and exact strategy. Min zooming means we will zoom into a frame by doing
// the minimal number of moves to get a frame into view - for example, if the view is large
// enough and the frame we are zooming to is just outside of the viewport to the right,
// we will only move the viewport to the right until the frame is in view. Exact strategy
// means we will zoom into the frame by moving the viewport to the exact location of the frame
// and setting the width of the view to that of the frame.
export function computeConfigViewWithStrategy(
strategy: 'min' | 'exact',
view: Rect,
frame: Rect
): Rect {
if (strategy === 'exact') {
return frame.withHeight(view.height);
}
if (strategy === 'min') {
// If frame is in view, do nothing
if (view.containsRect(frame)) {
return view;
}
// If view width <= frame width, we need to zoom out, so the behavior is the
// same as if we were using 'exact'
if (view.width <= frame.width) {
return frame.withHeight(view.height);
}
// If frame is to the left of the view, translate it left
// to frame.x so that start of the frame is in the view
let offset = view.clone();
if (frame.left < view.left) {
offset = offset.withX(frame.x);
} else if (frame.right > view.right) {
// If the right boundary of a frame is outside of the view, translate the view
// by the difference between the right edge of the frame and the right edge of the view
offset = view.withX(offset.x + frame.right - offset.right);
}
// If frame is above the view, translate view to top of frame
if (frame.bottom < view.top) {
offset = offset.withY(frame.top);
} else if (frame.bottom > view.bottom) {
// If frame is below the view, translate view by the difference
// of the bottom edge of the frame and the view
offset = offset.translateY(offset.y + frame.bottom - offset.bottom);
}
return offset;
}
return frame.withHeight(view.height);
}
export function computeMinZoomConfigViewForFrames(view: Rect, frames: Rect[]): Rect {
if (!frames.length) {
return view;
}
if (frames.length === 1) {
return new Rect(frames[0].x, frames[0].y, frames[0].width, view.height);
}
const frame = frames.reduce(
(min, f) => {
return {
x: Math.min(min.x, f.x),
y: Math.min(min.y, f.y),
right: Math.max(min.right, f.right),
bottom: 0,
};
},
{x: Number.MAX_SAFE_INTEGER, y: Number.MAX_SAFE_INTEGER, right: 0, bottom: 0}
);
return computeConfigViewWithStrategy(
'exact',
view,
new Rect(frame.x, frame.y, frame.right - frame.x, view.height)
);
}
// Compute the X and Y position based on offset and canvas resolution
export function getPhysicalSpacePositionFromOffset(offsetX: number, offsetY: number) {
const logicalMousePos = vec2.fromValues(offsetX, offsetY);
return vec2.scale(vec2.create(), logicalMousePos, window.devicePixelRatio);
}
export function getCenterScaleMatrixFromConfigPosition(scale: vec2, center: vec2) {
const invertedConfigCenter = vec2.fromValues(-center[0], -center[1]);
const centerScaleMatrix = mat3.create();
mat3.fromTranslation(centerScaleMatrix, center);
mat3.scale(centerScaleMatrix, centerScaleMatrix, scale);
mat3.translate(centerScaleMatrix, centerScaleMatrix, invertedConfigCenter);
return centerScaleMatrix;
}
// Translates the offsetX and offsetY into a config space position to find the center
// and apply the scaling transformation from there
export function getCenterScaleMatrixFromMousePosition(
scale: number,
cursor: vec2,
view: CanvasView<any>,
canvas: FlamegraphCanvas
): mat3 {
const configSpaceMouse = view.getConfigViewCursor(cursor, canvas);
const configCenter = vec2.fromValues(configSpaceMouse[0], view.configView.y);
return getCenterScaleMatrixFromConfigPosition(vec2.fromValues(scale, 1), configCenter);
}
export function getTranslationMatrixFromConfigSpace(deltaX: number, deltaY: number) {
const configDelta = vec2.fromValues(deltaX, deltaY);
return mat3.fromTranslation(mat3.create(), configDelta);
}
// Translates the offsetX and offsetY into a config space units and return a translation
// matrix that can be applied to the view
export function getTranslationMatrixFromPhysicalSpace(
deltaX: number,
deltaY: number,
view: CanvasView<any>,
canvas: FlamegraphCanvas,
multiplierX: number = 0.8,
multiplierY: number = 1
) {
const physicalDelta = vec2.fromValues(deltaX * multiplierX, deltaY * multiplierY);
const physicalToConfig = mat3.invert(
mat3.create(),
view.fromConfigView(canvas.physicalSpace)
);
const [m00, m01, m02, m10, m11, m12] = physicalToConfig;
const configDelta = vec2.transformMat3(vec2.create(), physicalDelta, [
m00,
m01,
m02,
m10,
m11,
m12,
0,
0,
0,
]);
return getTranslationMatrixFromConfigSpace(configDelta[0], configDelta[1]);
}
export function getConfigViewTranslationBetweenVectors(
offsetX: number,
offsetY: number,
start: vec2,
view: CanvasView<any>,
canvas: FlamegraphCanvas,
invert?: boolean
): mat3 | null {
const physicalMousePos = getPhysicalSpacePositionFromOffset(offsetX, offsetY);
const physicalDelta = invert
? vec2.subtract(vec2.create(), physicalMousePos, start)
: vec2.subtract(vec2.create(), start, physicalMousePos);
if (physicalDelta[0] === 0 && physicalDelta[1] === 0) {
return null;
}
const physicalToConfig = mat3.invert(
mat3.create(),
view.fromConfigView(canvas.physicalSpace)
);
const [m00, m01, m02, m10, m11, m12] = physicalToConfig;
const configDelta = vec2.transformMat3(vec2.create(), physicalDelta, [
m00,
m01,
m02,
m10,
m11,
m12,
0,
0,
0,
]);
return mat3.fromTranslation(mat3.create(), configDelta);
}
export function getConfigSpaceTranslationBetweenVectors(
offsetX: number,
offsetY: number,
start: vec2,
view: CanvasView<any>,
canvas: FlamegraphCanvas,
invert?: boolean
): mat3 | null {
const physicalMousePos = getPhysicalSpacePositionFromOffset(offsetX, offsetY);
const physicalDelta = invert
? vec2.subtract(vec2.create(), physicalMousePos, start)
: vec2.subtract(vec2.create(), start, physicalMousePos);
if (physicalDelta[0] === 0 && physicalDelta[1] === 0) {
return null;
}
const physicalToConfig = mat3.invert(
mat3.create(),
view.fromConfigSpace(canvas.physicalSpace)
);
const [m00, m01, m02, m10, m11, m12] = physicalToConfig;
const configDelta = vec2.transformMat3(vec2.create(), physicalDelta, [
m00,
m01,
m02,
m10,
m11,
m12,
0,
0,
0,
]);
return mat3.fromTranslation(mat3.create(), configDelta);
}
export function getMinimapCanvasCursor(
configView: Rect | undefined,
configSpaceCursor: vec2 | null,
borderWidth: number
) {
if (!configView || !configSpaceCursor) {
return 'col-resize';
}
const nearestEdge = Math.min(
Math.abs(configView.left - configSpaceCursor[0]),
Math.abs(configView.right - configSpaceCursor[0])
);
const isWithinBorderSize = nearestEdge <= borderWidth;
if (isWithinBorderSize) {
return 'ew-resize';
}
if (configView.contains(configSpaceCursor)) {
return 'grab';
}
return 'col-resize';
}
export function useResizeCanvasObserver(
canvases: (HTMLCanvasElement | null)[],
canvasPoolManager: CanvasPoolManager,
canvas: FlamegraphCanvas | null,
view: CanvasView<any> | null
): Rect {
const [bounds, setCanvasBounds] = useState<Rect>(Rect.Empty());
useLayoutEffect(() => {
if (!canvas || !canvases.length) {
return undefined;
}
if (canvases.some(c => c === null)) {
return undefined;
}
const observer = watchForResize(canvases as HTMLCanvasElement[], entries => {
const contentRect =
entries[0].contentRect ?? entries[0].target.getBoundingClientRect();
setCanvasBounds(
new Rect(contentRect.x, contentRect.y, contentRect.width, contentRect.height)
);
canvas.initPhysicalSpace();
if (view) {
view.resizeConfigSpace(canvas);
}
canvasPoolManager.drawSync();
});
return () => {
observer.disconnect();
};
}, [canvases, canvas, view, canvasPoolManager]);
return bounds;
}