utils.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684
  1. import Fuse from 'fuse.js';
  2. import {mat3, vec2} from 'gl-matrix';
  3. import {FlamegraphFrame} from 'sentry/utils/profiling/flamegraphFrame';
  4. import {FlamegraphRenderer} from 'sentry/utils/profiling/renderers/flamegraphRenderer';
  5. import {clamp} from '../colors/utils';
  6. export function createShader(
  7. gl: WebGLRenderingContext,
  8. type: WebGLRenderingContext['VERTEX_SHADER'] | WebGLRenderingContext['FRAGMENT_SHADER'],
  9. source: string
  10. ): WebGLShader {
  11. const shader = gl.createShader(type);
  12. if (!shader) {
  13. throw new Error('Could not create shader');
  14. }
  15. gl.shaderSource(shader, source);
  16. gl.compileShader(shader);
  17. const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
  18. if (success) {
  19. return shader;
  20. }
  21. gl.deleteShader(shader);
  22. throw new Error('Failed to compile shader');
  23. }
  24. export function createProgram(
  25. gl: WebGLRenderingContext,
  26. vertexShader: WebGLShader,
  27. fragmentShader: WebGLShader
  28. ): WebGLProgram {
  29. const program = gl.createProgram();
  30. if (!program) {
  31. throw new Error('Could not create program');
  32. }
  33. gl.attachShader(program, vertexShader);
  34. gl.attachShader(program, fragmentShader);
  35. gl.linkProgram(program);
  36. const success = gl.getProgramParameter(program, gl.LINK_STATUS);
  37. if (success) {
  38. return program;
  39. }
  40. gl.deleteProgram(program);
  41. throw new Error('Failed to create program');
  42. }
  43. // Create a projection matrix with origins at 0,0 in top left corner, scaled to width/height
  44. export function makeProjectionMatrix(width: number, height: number): mat3 {
  45. const projectionMatrix = mat3.create();
  46. mat3.identity(projectionMatrix);
  47. mat3.translate(projectionMatrix, projectionMatrix, vec2.fromValues(-1, 1));
  48. mat3.scale(
  49. projectionMatrix,
  50. projectionMatrix,
  51. vec2.divide(vec2.create(), vec2.fromValues(2, -2), vec2.fromValues(width, height))
  52. );
  53. return projectionMatrix;
  54. }
  55. const canvasToDisplaySizeMap = new Map<HTMLCanvasElement, [number, number]>();
  56. function onResize(entries: ResizeObserverEntry[]) {
  57. for (const entry of entries) {
  58. let width;
  59. let height;
  60. let dpr = window.devicePixelRatio;
  61. // @ts-ignore use as a progressive enhancement, some browsers don't support this yet
  62. if (entry.devicePixelContentBoxSize) {
  63. // NOTE: Only this path gives the correct answer
  64. // The other paths are imperfect fallbacks
  65. // for browsers that don't provide anyway to do this
  66. // @ts-ignore
  67. width = entry.devicePixelContentBoxSize[0].inlineSize;
  68. // @ts-ignore
  69. height = entry.devicePixelContentBoxSize[0].blockSize;
  70. dpr = 1; // it's already in width and height
  71. } else if (entry.contentBoxSize) {
  72. if (entry.contentBoxSize[0]) {
  73. width = entry.contentBoxSize[0].inlineSize;
  74. height = entry.contentBoxSize[0].blockSize;
  75. } else {
  76. // @ts-ignore
  77. width = entry.contentBoxSize.inlineSize;
  78. // @ts-ignore
  79. height = entry.contentBoxSize.blockSize;
  80. }
  81. } else {
  82. width = entry.contentRect.width;
  83. height = entry.contentRect.height;
  84. }
  85. const displayWidth = Math.round(width * dpr);
  86. const displayHeight = Math.round(height * dpr);
  87. canvasToDisplaySizeMap.set(entry.target as HTMLCanvasElement, [
  88. displayWidth,
  89. displayHeight,
  90. ]);
  91. resizeCanvasToDisplaySize(entry.target as HTMLCanvasElement);
  92. }
  93. }
  94. export const watchForResize = (
  95. canvas: HTMLCanvasElement[],
  96. callback?: () => void
  97. ): ResizeObserver => {
  98. const handler: ResizeObserverCallback = entries => {
  99. onResize(entries);
  100. callback?.();
  101. };
  102. for (const c of canvas) {
  103. canvasToDisplaySizeMap.set(c, [c.width, c.height]);
  104. }
  105. const resizeObserver = new ResizeObserver(handler);
  106. try {
  107. // only call us of the number of device pixels changed
  108. canvas.forEach(c => {
  109. resizeObserver.observe(c, {box: 'device-pixel-content-box'});
  110. });
  111. } catch (ex) {
  112. // device-pixel-content-box is not supported so fallback to this
  113. canvas.forEach(c => {
  114. resizeObserver.observe(c, {box: 'content-box'});
  115. });
  116. }
  117. return resizeObserver;
  118. };
  119. export function resizeCanvasToDisplaySize(canvas: HTMLCanvasElement): boolean {
  120. // Get the size the browser is displaying the canvas in device pixels.
  121. const size = canvasToDisplaySizeMap.get(canvas);
  122. if (!size) {
  123. const displayWidth = canvas.clientWidth * window.devicePixelRatio;
  124. const displayHeight = canvas.clientHeight * window.devicePixelRatio;
  125. canvas.width = displayWidth;
  126. canvas.height = displayHeight;
  127. return false;
  128. }
  129. const [displayWidth, displayHeight] = size;
  130. // Check if the canvas is not the same size.
  131. const needResize = canvas.width !== displayWidth || canvas.height !== displayHeight;
  132. if (needResize) {
  133. // Make the canvas the same size
  134. canvas.width = displayWidth;
  135. canvas.height = displayHeight;
  136. }
  137. return needResize;
  138. }
  139. export function transformMatrixBetweenRect(from: Rect, to: Rect): mat3 {
  140. return mat3.fromValues(
  141. to.width / from.width,
  142. 0,
  143. 0,
  144. 0,
  145. to.height / from.height,
  146. 0,
  147. to.x - from.x * (to.width / from.width),
  148. to.y - from.y * (to.height / from.height),
  149. 1
  150. );
  151. }
  152. // Utility class to manipulate a virtual rect element. Some of the implementations are based off
  153. // speedscope, however they are not 100% accurate and we've made some changes. It is important to
  154. // note that contructing a lot of these objects at draw time is expensive and should be avoided.
  155. export class Rect {
  156. origin: vec2;
  157. size: vec2;
  158. constructor(x: number, y: number, width: number, height: number) {
  159. this.origin = vec2.fromValues(x, y);
  160. this.size = vec2.fromValues(width, height);
  161. }
  162. clone(): Rect {
  163. return Rect.From(this);
  164. }
  165. isValid(): boolean {
  166. return this.toMatrix().every(n => !isNaN(n));
  167. }
  168. isEmpty(): boolean {
  169. return this.width === 0 && this.height === 0;
  170. }
  171. static Empty(): Rect {
  172. return new Rect(0, 0, 0, 0);
  173. }
  174. static From(rect: Rect): Rect {
  175. return new Rect(rect.x, rect.y, rect.width, rect.height);
  176. }
  177. get x(): number {
  178. return this.origin[0];
  179. }
  180. get y(): number {
  181. return this.origin[1];
  182. }
  183. get width(): number {
  184. return this.size[0];
  185. }
  186. get height(): number {
  187. return this.size[1];
  188. }
  189. get left(): number {
  190. return this.x;
  191. }
  192. get right(): number {
  193. return this.left + this.width;
  194. }
  195. get top(): number {
  196. return this.y;
  197. }
  198. get bottom(): number {
  199. return this.top + this.height;
  200. }
  201. static decode(query: string | ReadonlyArray<string> | null | undefined): Rect | null {
  202. let maybeEncodedRect = query;
  203. if (typeof query === 'string') {
  204. maybeEncodedRect = query.split(',');
  205. }
  206. if (!Array.isArray(maybeEncodedRect)) {
  207. return null;
  208. }
  209. if (maybeEncodedRect.length !== 4) {
  210. return null;
  211. }
  212. const rect = new Rect(
  213. ...(maybeEncodedRect.map(p => parseFloat(p)) as [number, number, number, number])
  214. );
  215. if (rect.isValid()) {
  216. return rect;
  217. }
  218. return null;
  219. }
  220. static encode(rect: Rect): string {
  221. return rect.toString();
  222. }
  223. toString() {
  224. return [this.x, this.y, this.width, this.height].map(n => Math.round(n)).join(',');
  225. }
  226. toMatrix(): mat3 {
  227. const {width: w, height: h, x, y} = this;
  228. // it's easier to display a matrix as a 3x3 array. WebGl matrices are row first and not column first
  229. // https://webglfundamentals.org/webgl/lessons/webgl-matrix-vs-math.html
  230. // prettier-ignore
  231. return mat3.fromValues(
  232. w, 0, 0,
  233. 0, h, 0,
  234. x, y, 1
  235. )
  236. }
  237. hasIntersectionWith(other: Rect): boolean {
  238. const top = Math.max(this.top, other.top);
  239. const bottom = Math.max(top, Math.min(this.bottom, other.bottom));
  240. if (bottom - top === 0) {
  241. return false;
  242. }
  243. const left = Math.max(this.left, other.left);
  244. const right = Math.max(left, Math.min(this.right, other.right));
  245. if (right - left === 0) {
  246. return false;
  247. }
  248. return true;
  249. }
  250. containsX(vec: vec2): boolean {
  251. return vec[0] >= this.left && vec[0] <= this.right;
  252. }
  253. containsY(vec: vec2): boolean {
  254. return vec[1] >= this.top && vec[1] <= this.bottom;
  255. }
  256. contains(vec: vec2): boolean {
  257. return this.containsX(vec) && this.containsY(vec);
  258. }
  259. containsRect(rect: Rect): boolean {
  260. return (
  261. // left bound
  262. this.left <= rect.left &&
  263. // right bound
  264. rect.right <= this.right &&
  265. // top bound
  266. this.top <= rect.top &&
  267. // bottom bound
  268. rect.bottom <= this.bottom
  269. );
  270. }
  271. leftOverlapsWith(rect: Rect): boolean {
  272. return rect.left <= this.left && rect.right >= this.left;
  273. }
  274. rightOverlapsWith(rect: Rect): boolean {
  275. return this.right >= rect.left && this.right <= rect.right;
  276. }
  277. overlapsX(other: Rect): boolean {
  278. return this.left <= other.right && this.right >= other.left;
  279. }
  280. overlapsY(other: Rect): boolean {
  281. return this.top <= other.bottom && this.bottom >= other.top;
  282. }
  283. overlaps(other: Rect): boolean {
  284. return this.overlapsX(other) && this.overlapsY(other);
  285. }
  286. transformRect(transform: mat3): Rect {
  287. const x = this.x * transform[0] + this.y * transform[3] + transform[6];
  288. const y = this.x * transform[1] + this.y * transform[4] + transform[7];
  289. const width = this.width * transform[0] + this.height * transform[3];
  290. const height = this.width * transform[1] + this.height * transform[4];
  291. return new Rect(
  292. x + (width < 0 ? width : 0),
  293. y + (height < 0 ? height : 0),
  294. Math.abs(width),
  295. Math.abs(height)
  296. );
  297. }
  298. /**
  299. * Returns a transform that inverts the y axis within the rect.
  300. * This causes the bottom of the rect to be the top of the rect and vice versa.
  301. */
  302. invertYTransform(): mat3 {
  303. return mat3.fromValues(1, 0, 0, 0, -1, 0, 0, this.y * 2 + this.height, 1);
  304. }
  305. withHeight(height: number): Rect {
  306. return new Rect(this.x, this.y, this.width, height);
  307. }
  308. withWidth(width: number): Rect {
  309. return new Rect(this.x, this.y, width, this.height);
  310. }
  311. withX(x: number): Rect {
  312. return new Rect(x, this.y, this.width, this.height);
  313. }
  314. withY(y: number) {
  315. return new Rect(this.x, y, this.width, this.height);
  316. }
  317. toBounds(): [number, number, number, number] {
  318. return [this.x, this.y, this.x + this.width, this.y + this.height];
  319. }
  320. toArray(): [number, number, number, number] {
  321. return [this.x, this.y, this.width, this.height];
  322. }
  323. between(to: Rect): Rect {
  324. return new Rect(to.x, to.y, to.width / this.width, to.height / this.height);
  325. }
  326. translate(x: number, y: number): Rect {
  327. return new Rect(x, y, this.width, this.height);
  328. }
  329. translateX(x: number): Rect {
  330. return new Rect(x, this.y, this.width, this.height);
  331. }
  332. translateY(y: number): Rect {
  333. return new Rect(this.x, y, this.width, this.height);
  334. }
  335. scaleX(x: number): Rect {
  336. return new Rect(this.x, this.y, this.width * x, this.height);
  337. }
  338. scaleY(y: number): Rect {
  339. return new Rect(this.x, this.y, this.width, this.height * y);
  340. }
  341. scale(x: number, y: number): Rect {
  342. return new Rect(this.x * x, this.y * y, this.width * x, this.height * y);
  343. }
  344. scaleOriginBy(x: number, y: number): Rect {
  345. return new Rect(this.x * x, this.y * y, this.width, this.height);
  346. }
  347. scaledBy(x: number, y: number): Rect {
  348. return new Rect(this.x, this.y, this.width * x, this.height * y);
  349. }
  350. equals(rect: Rect): boolean {
  351. if (this.x !== rect.x) {
  352. return false;
  353. }
  354. if (this.y !== rect.y) {
  355. return false;
  356. }
  357. if (this.width !== rect.width) {
  358. return false;
  359. }
  360. if (this.height !== rect.height) {
  361. return false;
  362. }
  363. return true;
  364. }
  365. notEqualTo(rect: Rect): boolean {
  366. return !this.equals(rect);
  367. }
  368. }
  369. function getContext(canvas: HTMLCanvasElement, context: '2d'): CanvasRenderingContext2D;
  370. function getContext(canvas: HTMLCanvasElement, context: 'webgl'): WebGLRenderingContext;
  371. function getContext(canvas: HTMLCanvasElement, context: string): RenderingContext {
  372. const ctx =
  373. context === 'webgl'
  374. ? canvas.getContext(context, {antialias: false})
  375. : canvas.getContext(context);
  376. if (!ctx) {
  377. throw new Error(`Could not get context ${context}`);
  378. }
  379. return ctx;
  380. }
  381. // Exported separately as writing export function for each overload as
  382. // breaks the line width rules and makes it harder to read.
  383. export {getContext};
  384. export function measureText(string: string, ctx?: CanvasRenderingContext2D): Rect {
  385. if (!string) {
  386. return Rect.Empty();
  387. }
  388. const context = ctx || getContext(document.createElement('canvas'), '2d');
  389. const measures = context.measureText(string);
  390. return new Rect(
  391. 0,
  392. 0,
  393. measures.width,
  394. // https://stackoverflow.com/questions/1134586/how-can-you-find-the-height-of-text-on-an-html-canvas
  395. measures.actualBoundingBoxAscent + measures.actualBoundingBoxDescent
  396. );
  397. }
  398. // Taken from speedscope, computes min/max by halving the high/low end
  399. // of the range on each iteration as long as range precision is greater than the given precision.
  400. export function findRangeBinarySearch(
  401. {low, high}: {high: number; low: number},
  402. fn: (val: number) => number,
  403. target: number,
  404. precision = 1
  405. ): [number, number] {
  406. // eslint-disable-next-line
  407. while (true) {
  408. if (high - low <= precision) {
  409. return [low, high];
  410. }
  411. const mid = (high + low) / 2;
  412. if (fn(mid) < target) {
  413. low = mid;
  414. } else {
  415. high = mid;
  416. }
  417. }
  418. }
  419. export function formatColorForFrame(
  420. frame: FlamegraphFrame,
  421. renderer: FlamegraphRenderer
  422. ): string {
  423. const color = renderer.getColorForFrame(frame);
  424. if (color.length === 4) {
  425. return `rgba(${color
  426. .slice(0, 3)
  427. .map(n => n * 255)
  428. .join(',')}, ${color[3]})`;
  429. }
  430. return `rgba(${color.map(n => n * 255).join(',')}, 1.0)`;
  431. }
  432. export const ELLIPSIS = '\u2026';
  433. type TrimTextCenter = {
  434. end: number;
  435. length: number;
  436. start: number;
  437. text: string;
  438. };
  439. // Similar to speedscope's implementation, utility fn to trim text in the center with a small bias towards prefixes.
  440. export function trimTextCenter(text: string, low: number): TrimTextCenter {
  441. if (low >= text.length) {
  442. return {
  443. text,
  444. start: 0,
  445. end: 0,
  446. length: 0,
  447. };
  448. }
  449. const prefixLength = Math.floor(low / 2);
  450. // Use 1 character less than the low value to account for ellipsis and favor displaying the prefix
  451. const postfixLength = low - prefixLength - 1;
  452. const start = prefixLength;
  453. const end = Math.floor(text.length - postfixLength + ELLIPSIS.length);
  454. const trimText = `${text.substring(0, start)}${ELLIPSIS}${text.substring(end)}`;
  455. return {
  456. text: trimText,
  457. start,
  458. end,
  459. length: end - start,
  460. };
  461. }
  462. // Utility function to compute a clamped view. This is essentially a bounds check
  463. // to ensure that zoomed viewports stays in the bounds and does not escape the view.
  464. export function computeClampedConfigView(
  465. newConfigView: Rect,
  466. {width, height}: {height: {max: number; min: number}; width: {max: number; min: number}}
  467. ) {
  468. if (!newConfigView.isValid()) {
  469. throw new Error(newConfigView.toString());
  470. }
  471. const clampedWidth = clamp(newConfigView.width, width.min, width.max);
  472. const clampedHeight = clamp(newConfigView.height, height.min, height.max);
  473. const maxX = width.max - clampedWidth;
  474. const maxY = clampedHeight >= height.max ? 0 : height.max - clampedHeight;
  475. const clampedX = clamp(newConfigView.x, 0, maxX);
  476. const clampedY = clamp(newConfigView.y, 0, maxY);
  477. return new Rect(clampedX, clampedY, clampedWidth, clampedHeight);
  478. }
  479. /**
  480. * computeHighlightedBounds determines if a supplied boundary should be reduced in size
  481. * or shifted based on the results of a trim operation
  482. */
  483. export function computeHighlightedBounds(
  484. bounds: Fuse.RangeTuple,
  485. trim: TrimTextCenter
  486. ): Fuse.RangeTuple {
  487. if (!trim.length) {
  488. return bounds;
  489. }
  490. const isStartBetweenTrim = bounds[0] >= trim.start && bounds[0] <= trim.end;
  491. const isEndBetweenTrim = bounds[1] >= trim.start && bounds[1] <= trim.end;
  492. const isFullyTruncated = isStartBetweenTrim && isEndBetweenTrim;
  493. // example:
  494. // -[UIScrollView _smoothScrollDisplayLink:]
  495. // "smooth" in "-[UIScrollView _…ScrollDisplayLink:]"
  496. // ^^
  497. if (isFullyTruncated) {
  498. return [trim.start, trim.start + 1];
  499. }
  500. if (bounds[0] < trim.start) {
  501. // "ScrollView" in '-[UIScrollView _sm…rollDisplayLink:]'
  502. // ^--------^
  503. if (bounds[1] < trim.start) {
  504. return [bounds[0], bounds[1]];
  505. }
  506. // "smoothScroll" in -[UIScrollView _smooth…DisplayLink:]'
  507. // ^-----^
  508. if (isEndBetweenTrim) {
  509. return [bounds[0], trim.start + 1];
  510. }
  511. // "smoothScroll" in -[UIScrollView _sm…llDisplayLink:]'
  512. // ^---^
  513. if (bounds[1] > trim.end) {
  514. return [bounds[0], bounds[1] - trim.length + 1];
  515. }
  516. }
  517. // "smoothScroll" in -[UIScrollView _…scrollDisplayLink:]'
  518. // ^-----^
  519. if (isStartBetweenTrim && bounds[1] > trim.end) {
  520. return [trim.start, bounds[1] - trim.length + 1];
  521. }
  522. // "display" in -[UIScrollView _…scrollDisplayLink:]'
  523. // ^-----^
  524. if (bounds[0] > trim.end) {
  525. return [bounds[0] - trim.length + 1, bounds[1] - trim.length + 1];
  526. }
  527. throw new Error(`Unhandled case: ${JSON.stringify(bounds)} ${trim}`);
  528. }
  529. // Utility function to allow zooming into frames using a specific strategy. Supports
  530. // min zooming and exact strategy. Min zooming means we will zoom into a frame by doing
  531. // the minimal number of moves to get a frame into view - for example, if the view is large
  532. // enough and the frame we are zooming to is just outside of the viewport to the right,
  533. // we will only move the viewport to the right until the frame is in view. Exact strategy
  534. // means we will zoom into the frame by moving the viewport to the exact location of the frame
  535. // and setting the width of the view to that of the frame.
  536. export function computeConfigViewWithStategy(
  537. strategy: 'min' | 'exact',
  538. view: Rect,
  539. frame: Rect
  540. ): Rect {
  541. if (strategy === 'exact') {
  542. return frame.withHeight(view.height);
  543. }
  544. if (strategy === 'min') {
  545. if (view.width <= frame.width) {
  546. // If view width <= frame width, we need to zoom out, so the behavior is the
  547. // same as if we were using 'exact'
  548. return frame.withHeight(view.height);
  549. }
  550. if (view.containsRect(frame)) {
  551. // If frame is in view, do nothing
  552. return view;
  553. }
  554. let offset = view.clone();
  555. if (frame.left < view.left) {
  556. // If frame is to the left of the view, translate it left
  557. // to frame.x so that start of the frame is in the view
  558. offset = offset.withX(frame.x);
  559. } else if (frame.right > view.right) {
  560. // If the right boundary of a frame is outside of the view, translate the view
  561. // by the difference between the right edge of the frame and the right edge of the view
  562. offset = view.withX(offset.x + frame.right - offset.right);
  563. }
  564. if (frame.bottom < view.top) {
  565. // If frame is above the view, translate view to top of frame
  566. offset = offset.withY(frame.top);
  567. } else if (frame.bottom > view.bottom) {
  568. // If frame is below the view, translate view by the difference
  569. // of the bottom edge of the frame and the view
  570. offset = offset.translateY(offset.y + frame.bottom - offset.bottom);
  571. }
  572. return offset;
  573. }
  574. return frame.withHeight(view.height);
  575. }