textRenderer.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  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. // Make sure we set font size before we measure text for the first draw
  53. const FONT_SIZE = this.theme.SIZES.BAR_FONT_SIZE * window.devicePixelRatio;
  54. this.context.font = `${FONT_SIZE}px ${this.theme.FONTS.FRAME_FONT}`;
  55. this.context.textBaseline = 'alphabetic';
  56. this.maybeInvalidateCache();
  57. const MIN_WIDTH = this.measureAndCacheText(ELLIPSIS).width;
  58. const SIDE_PADDING = 2 * this.theme.SIZES.BAR_PADDING * window.devicePixelRatio;
  59. const HALF_SIDE_PADDING = SIDE_PADDING / 2;
  60. const BASELINE_OFFSET =
  61. (this.theme.SIZES.BAR_HEIGHT - this.theme.SIZES.BAR_FONT_SIZE / 2) *
  62. window.devicePixelRatio;
  63. const HIGHLIGHT_BACKGROUND_COLOR = `rgb(${this.theme.COLORS.HIGHLIGHTED_LABEL_COLOR.join(
  64. ', '
  65. )})`;
  66. const TOP_BOUNDARY = configView.top - 1;
  67. const BOTTOM_BOUNDARY = configView.bottom + 1;
  68. const HAS_SEARCH_RESULTS = flamegraphSearchResults.size > 0;
  69. // We start by iterating over root frames, so we draw the call stacks top-down.
  70. // This allows us to do a couple optimizations that improve our best case performance.
  71. // 1. We can skip drawing the entire tree if the root frame is not visible
  72. // 2. We can skip drawing and
  73. const frames: FlamegraphFrame[] = [...this.flamegraph.root.children];
  74. while (frames.length > 0) {
  75. const frame = frames.pop()!;
  76. // Check if our rect overlaps with the current viewport and skip it
  77. if (frame.end < configView.left || frame.start > configView.right) {
  78. continue;
  79. }
  80. // We pin the start and end of the frame, so scrolling around keeps text pinned to the left or right side of the viewport
  81. const pinnedStart = Math.max(frame.start, configView.left);
  82. const pinnedEnd = Math.min(frame.end, configView.right);
  83. // Transform frame to physical space coordinates. This does the same operation as
  84. // Rect.transformRect, but without allocating a new Rect object.
  85. const frameWidth =
  86. (pinnedEnd - pinnedStart) * configViewToPhysicalSpace[0] +
  87. configViewToPhysicalSpace[3];
  88. // Since the text is not exactly aligned to the left/right bounds of the frame, we need to subtract the padding
  89. // from the total width, so that we can truncate the center of the text accurately.
  90. const paddedRectangleWidth = frameWidth - SIDE_PADDING;
  91. // Since children of a frame cannot be wider than the frame itself, we can exit early and discard the entire subtree
  92. if (paddedRectangleWidth <= MIN_WIDTH) {
  93. continue;
  94. }
  95. if (frame.depth > BOTTOM_BOUNDARY) {
  96. continue;
  97. }
  98. for (let i = 0; i < frame.children.length; i++) {
  99. frames.push(frame.children[i]);
  100. }
  101. // If a frame is lower than the top, we can skip drawing its text, however
  102. // we can only do so after we have pushed it's children into the queue or else
  103. // those children will never be drawn and the entire sub-tree will be skipped.
  104. if (frame.depth < TOP_BOUNDARY) {
  105. continue;
  106. }
  107. // Transform frame to physical space coordinates. This does the same operation as
  108. // Rect.transformRect, but without allocating a new Rect object.
  109. const frameHeight =
  110. (pinnedEnd - pinnedStart) * configViewToPhysicalSpace[1] +
  111. configViewToPhysicalSpace[4];
  112. const frameX =
  113. pinnedStart * configViewToPhysicalSpace[0] +
  114. frame.depth * configViewToPhysicalSpace[3] +
  115. configViewToPhysicalSpace[6];
  116. const frameY =
  117. pinnedStart * configViewToPhysicalSpace[1] +
  118. frame.depth * configViewToPhysicalSpace[4] +
  119. configViewToPhysicalSpace[7];
  120. // We want to draw the text in the vertical center of the frame, so we substract half the height of the text.
  121. // Since the origin of the rect in the inverted view is also inverted, we need to add the height.
  122. const y = frameY + (frameHeight < 0 ? frameHeight : 0) + BASELINE_OFFSET;
  123. const x = frameX + (frameWidth < 0 ? frameWidth : 0) + HALF_SIDE_PADDING;
  124. const trim = trimTextCenter(
  125. frame.frame.name,
  126. findRangeBinarySearch(
  127. {low: 0, high: paddedRectangleWidth},
  128. n => this.measureAndCacheText(frame.frame.name.substring(0, n)).width,
  129. paddedRectangleWidth
  130. )[0]
  131. );
  132. if (HAS_SEARCH_RESULTS) {
  133. const frameId = getFlamegraphFrameSearchId(frame);
  134. const frameResults = flamegraphSearchResults.get(frameId);
  135. if (frameResults) {
  136. this.context.fillStyle = HIGHLIGHT_BACKGROUND_COLOR;
  137. const highlightedBounds = computeHighlightedBounds(frameResults.match, trim);
  138. const frontMatter = trim.text.slice(0, highlightedBounds[0]);
  139. const highlightWidth = this.measureAndCacheText(
  140. trim.text.substring(highlightedBounds[0], highlightedBounds[1])
  141. ).width;
  142. this.context.fillRect(
  143. x + this.measureAndCacheText(frontMatter).width,
  144. y + FONT_SIZE / 2 - BASELINE_OFFSET,
  145. highlightWidth,
  146. FONT_SIZE
  147. );
  148. }
  149. }
  150. this.context.fillStyle = this.theme.COLORS.LABEL_FONT_COLOR;
  151. this.context.fillText(trim.text, x, y);
  152. }
  153. }
  154. }
  155. export {TextRenderer};