textRenderer.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. import {mat3} from 'gl-matrix';
  2. import {Flamegraph} from '../flamegraph';
  3. import {FlamegraphSearch} from '../flamegraph/flamegraphStateProvider/flamegraphSearch';
  4. import {FlamegraphTheme} from '../flamegraph/flamegraphTheme';
  5. import {FlamegraphFrame, getFlamegraphFrameSearchId} from '../flamegraphFrame';
  6. import {
  7. computeHighlightedBounds,
  8. ELLIPSIS,
  9. findRangeBinarySearch,
  10. getContext,
  11. Rect,
  12. resizeCanvasToDisplaySize,
  13. trimTextCenter,
  14. } from '../gl/utils';
  15. const TEST_STRING = 'Who knows if this changed, font-display: swap wont tell me';
  16. class TextRenderer {
  17. canvas: HTMLCanvasElement;
  18. theme: FlamegraphTheme;
  19. flamegraph: Flamegraph;
  20. context: CanvasRenderingContext2D;
  21. textCache: Record<string, TextMetrics>;
  22. constructor(canvas: HTMLCanvasElement, flamegraph: Flamegraph, theme: FlamegraphTheme) {
  23. this.canvas = canvas;
  24. this.theme = theme;
  25. this.flamegraph = flamegraph;
  26. this.context = getContext(canvas, '2d');
  27. this.textCache = {};
  28. resizeCanvasToDisplaySize(canvas);
  29. }
  30. measureAndCacheText(text: string): TextMetrics {
  31. if (this.textCache[text]) {
  32. return this.textCache[text];
  33. }
  34. this.textCache[text] = this.context.measureText(text);
  35. return this.textCache[text];
  36. }
  37. maybeInvalidateCache(): void {
  38. if (this.textCache[TEST_STRING] === undefined) {
  39. this.measureAndCacheText(TEST_STRING);
  40. return;
  41. }
  42. const newMeasuredSize = this.context.measureText(TEST_STRING);
  43. if (newMeasuredSize !== this.textCache[TEST_STRING]) {
  44. this.textCache = {[TEST_STRING]: newMeasuredSize};
  45. }
  46. }
  47. draw(
  48. configView: Rect,
  49. configViewToPhysicalSpace: mat3,
  50. flamegraphSearchResults: FlamegraphSearch['results']
  51. ): void {
  52. this.maybeInvalidateCache();
  53. const fontSize = this.theme.SIZES.BAR_FONT_SIZE * window.devicePixelRatio;
  54. this.context.font = `${fontSize}px ${this.theme.FONTS.FRAME_FONT}`;
  55. this.context.textBaseline = 'alphabetic';
  56. const minWidth = this.measureAndCacheText(ELLIPSIS).width;
  57. const SIDE_PADDING = 2 * this.theme.SIZES.BAR_PADDING * window.devicePixelRatio;
  58. const HALF_SIDE_PADDING = SIDE_PADDING / 2;
  59. const BASELINE_OFFSET =
  60. (this.theme.SIZES.BAR_HEIGHT - this.theme.SIZES.BAR_FONT_SIZE / 2) *
  61. window.devicePixelRatio;
  62. const HIGHLIGHT_BACKGROUND_COLOR = `rgb(${this.theme.COLORS.HIGHLIGHTED_LABEL_COLOR.join(
  63. ', '
  64. )})`;
  65. const TOP_BOUNDARY = configView.top - 1;
  66. const BOTTOM_BOUNDARY = configView.bottom + 1;
  67. const HAS_SEARCH_RESULTS = flamegraphSearchResults.size > 0;
  68. // We start by iterating over root frames, so we draw the call stacks top-down.
  69. // This allows us to do a couple optimizations that improve our best case performance.
  70. // 1. We can skip drawing the entire tree if the root frame is not visible
  71. // 2. We can skip drawing and
  72. const frames: FlamegraphFrame[] = [...this.flamegraph.root.children];
  73. while (frames.length > 0) {
  74. const frame = frames.pop()!;
  75. // Check if our rect overlaps with the current viewport and skip it
  76. if (frame.end < configView.left || frame.start > configView.right) {
  77. continue;
  78. }
  79. // We pin the start and end of the frame, so scrolling around keeps text pinned to the left or right side of the viewport
  80. const pinnedStart = Math.max(frame.start, configView.left);
  81. const pinnedEnd = Math.min(frame.end, configView.right);
  82. // Transform frame to physical space coordinates. This does the same operation as
  83. // Rect.transformRect, but without allocating a new Rect object.
  84. const frameWidth =
  85. (pinnedEnd - pinnedStart) * configViewToPhysicalSpace[0] +
  86. configViewToPhysicalSpace[3];
  87. // Since the text is not exactly aligned to the left/right bounds of the frame, we need to subtract the padding
  88. // from the total width, so that we can truncate the center of the text accurately.
  89. const paddedRectangleWidth = frameWidth - SIDE_PADDING;
  90. // Since children of a frame cannot be wider than the frame itself, we can exit early and discard the entire subtree
  91. if (paddedRectangleWidth <= minWidth) {
  92. continue;
  93. }
  94. if (frame.depth > BOTTOM_BOUNDARY) {
  95. continue;
  96. }
  97. for (let i = 0; i < frame.children.length; i++) {
  98. frames.push(frame.children[i]);
  99. }
  100. // If a frame is lower than the top, we can skip drawing its text, however
  101. // we can only do so after we have pushed it's children into the queue or else
  102. // those children will never be drawn and the entire sub-tree will be skipped.
  103. if (frame.depth < TOP_BOUNDARY) {
  104. continue;
  105. }
  106. // Transform frame to physical space coordinates. This does the same operation as
  107. // Rect.transformRect, but without allocating a new Rect object.
  108. const frameHeight =
  109. (pinnedEnd - pinnedStart) * configViewToPhysicalSpace[1] +
  110. configViewToPhysicalSpace[4];
  111. const frameX =
  112. pinnedStart * configViewToPhysicalSpace[0] +
  113. frame.depth * configViewToPhysicalSpace[3] +
  114. configViewToPhysicalSpace[6];
  115. const frameY =
  116. pinnedStart * configViewToPhysicalSpace[1] +
  117. frame.depth * configViewToPhysicalSpace[4] +
  118. configViewToPhysicalSpace[7];
  119. // We want to draw the text in the vertical center of the frame, so we substract half the height of the text
  120. const y = frameY + BASELINE_OFFSET;
  121. // Offset x by 1x the padding
  122. const x = frameX + (frameWidth < 0 ? frameWidth : 0) + HALF_SIDE_PADDING;
  123. const trim = trimTextCenter(
  124. frame.frame.name,
  125. findRangeBinarySearch(
  126. {low: 0, high: paddedRectangleWidth},
  127. n => this.measureAndCacheText(frame.frame.name.substring(0, n)).width,
  128. paddedRectangleWidth
  129. )[0]
  130. );
  131. if (HAS_SEARCH_RESULTS) {
  132. const frameId = getFlamegraphFrameSearchId(frame);
  133. const frameResults = flamegraphSearchResults.get(frameId);
  134. if (frameResults) {
  135. this.context.fillStyle = HIGHLIGHT_BACKGROUND_COLOR;
  136. const highlightedBounds = computeHighlightedBounds(frameResults.match, trim);
  137. const frontMatter = trim.text.slice(0, highlightedBounds[0]);
  138. const highlightWidth = this.measureAndCacheText(
  139. trim.text.substring(highlightedBounds[0], highlightedBounds[1])
  140. ).width;
  141. this.context.fillRect(
  142. x + this.measureAndCacheText(frontMatter).width,
  143. frameY + (frameHeight < 0 ? frameHeight : 0) + fontSize / 2,
  144. highlightWidth,
  145. fontSize
  146. );
  147. }
  148. }
  149. this.context.fillStyle = this.theme.COLORS.LABEL_FONT_COLOR;
  150. this.context.fillText(trim.text, x, y);
  151. }
  152. }
  153. }
  154. export {TextRenderer};