chartRenderer.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import {mat3, vec2, vec3} from 'gl-matrix';
  2. import {FlamegraphTheme} from 'sentry/utils/profiling/flamegraph/flamegraphTheme';
  3. import {FlamegraphChart} from 'sentry/utils/profiling/flamegraphChart';
  4. import {
  5. getContext,
  6. lowerBound,
  7. resizeCanvasToDisplaySize,
  8. upperBound,
  9. } from 'sentry/utils/profiling/gl/utils';
  10. import {Rect} from 'sentry/utils/profiling/speedscope';
  11. function findYIntervals(
  12. configView: Rect,
  13. logicalSpaceToConfigView: mat3,
  14. getInterval: (mat: mat3, x: number) => number
  15. ): number[] {
  16. const target = 30;
  17. const targetInterval = Math.abs(
  18. getInterval(logicalSpaceToConfigView, target) - configView.bottom
  19. );
  20. const minInterval = Math.pow(10, Math.floor(Math.log10(targetInterval)));
  21. let interval = minInterval;
  22. if (targetInterval / interval > 3) {
  23. interval *= 3;
  24. }
  25. if (targetInterval / interval > 2) {
  26. interval *= 2;
  27. }
  28. let x = Math.ceil(configView.top / interval) * interval;
  29. const intervals: number[] = [];
  30. while (x <= configView.bottom) {
  31. intervals.push(x);
  32. x += interval;
  33. }
  34. return intervals;
  35. }
  36. function binaryFindNearest(
  37. serie: FlamegraphChart['series'][0],
  38. target: number,
  39. tolerance: number
  40. ): number | null {
  41. if (!serie.points.length) {
  42. return null;
  43. }
  44. if (target < serie.points[0].x) {
  45. return null;
  46. }
  47. if (target > serie.points[serie.points.length - 1].x) {
  48. return null;
  49. }
  50. let left = 0;
  51. let right = serie.points.length - 1;
  52. while (left <= right) {
  53. const mid = Math.floor(left + (right - left) / 2);
  54. const point = serie.points[mid];
  55. if (Math.abs(point.x - target) < tolerance) {
  56. return mid;
  57. }
  58. if (point.x < target) {
  59. left = mid + 1;
  60. }
  61. if (point.x > target) {
  62. right = mid - 1;
  63. }
  64. }
  65. return null;
  66. }
  67. function getIntervalTimeAtY(logicalSpaceToConfigView: mat3, y: number): number {
  68. const vector = logicalSpaceToConfigView[4] * y + logicalSpaceToConfigView[7];
  69. if (vector > 1) {
  70. return Math.round(vector);
  71. }
  72. return Math.round(vector * 10) / 10;
  73. }
  74. export class FlamegraphChartRenderer {
  75. canvas: HTMLCanvasElement | null;
  76. chart: FlamegraphChart;
  77. context: CanvasRenderingContext2D;
  78. theme: FlamegraphTheme;
  79. constructor(canvas: HTMLCanvasElement, chart: FlamegraphChart, theme: FlamegraphTheme) {
  80. this.canvas = canvas;
  81. this.chart = chart;
  82. this.theme = theme;
  83. this.context = getContext(this.canvas, '2d');
  84. resizeCanvasToDisplaySize(this.canvas);
  85. }
  86. findHoveredSeries(
  87. _configSpaceCursor: vec2,
  88. tolerance: number
  89. ): FlamegraphChart['series'] {
  90. const matches: FlamegraphChart['series'] = [];
  91. for (let i = 0; i < this.chart.series.length; i++) {
  92. const index = binaryFindNearest(
  93. this.chart.series[i],
  94. _configSpaceCursor[0],
  95. tolerance
  96. );
  97. if (index !== null) {
  98. matches.push({
  99. name: this.chart.series[i].name,
  100. type: this.chart.series[i].type,
  101. lineColor: this.chart.series[i].lineColor,
  102. fillColor: this.chart.series[i].fillColor,
  103. points: [this.chart.series[i].points[index]],
  104. });
  105. }
  106. }
  107. return matches;
  108. }
  109. draw(
  110. configView: Rect,
  111. configViewToPhysicalSpace: mat3,
  112. logicalSpaceToConfigView: mat3,
  113. configSpaceCursorRef: React.RefObject<vec2 | null>
  114. ) {
  115. if (!this.canvas) {
  116. throw new Error('No canvas to draw on');
  117. }
  118. this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
  119. if (!this.chart.series.length) {
  120. return;
  121. }
  122. this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
  123. this.context.font = `bold ${
  124. this.theme.SIZES.METRICS_FONT_SIZE * window.devicePixelRatio
  125. }px ${this.theme.FONTS.FRAME_FONT}`;
  126. this.context.beginPath();
  127. this.context.stroke();
  128. const intervals = findYIntervals(
  129. configView,
  130. logicalSpaceToConfigView,
  131. getIntervalTimeAtY
  132. );
  133. this.context.textBaseline = 'bottom';
  134. this.context.lineWidth = 1;
  135. const TICK_WIDTH = 14 * window.devicePixelRatio;
  136. const {left, right} = configView.transformRect(configViewToPhysicalSpace);
  137. const textOffsetLeft = 2 * window.devicePixelRatio;
  138. const origin = vec3.fromValues(0, 0, 1);
  139. const space = vec3.fromValues(configView.width, configView.height, 1);
  140. vec3.transformMat3(origin, origin, configViewToPhysicalSpace);
  141. vec3.transformMat3(space, space, configViewToPhysicalSpace);
  142. // Draw series
  143. for (let i = 0; i < this.chart.series.length; i++) {
  144. this.context.lineWidth = 1 * window.devicePixelRatio;
  145. this.context.fillStyle = this.chart.series[i].fillColor;
  146. this.context.strokeStyle = this.chart.series[i].lineColor;
  147. this.context.lineCap = 'round';
  148. this.context.beginPath();
  149. const serie = this.chart.series[i];
  150. let start = lowerBound(configView.left, serie.points, a => a.x);
  151. let end = upperBound(configView.right, serie.points, a => a.x);
  152. // Bounds are inclusive, so we adjust start and end by 1. This ensures we
  153. // draw the previous/next line that goes outside of bounds.
  154. // If we dont do this, the chart looks like | -- | instead of |----|
  155. if (start > 0) {
  156. start = start - 1;
  157. }
  158. if (end < serie.points.length) {
  159. end = end + 1;
  160. }
  161. for (let j = start; j < end; j++) {
  162. const point = serie.points[j];
  163. const r = vec3.fromValues(point.x, point.y, 1);
  164. vec3.transformMat3(r, r, configViewToPhysicalSpace);
  165. if (serie.type === 'area' && j === start) {
  166. this.context.lineTo(r[0], origin[1]);
  167. }
  168. this.context.lineTo(r[0], r[1]);
  169. if (serie.type === 'area' && j === end - 1) {
  170. this.context.lineTo(r[0], origin[1]);
  171. }
  172. // Enable to see dots drawn for each point
  173. // this.context.arc(r[0], r[1], 2, 0, 2 * Math.PI);
  174. }
  175. if (this.chart.series[i].type === 'line') {
  176. this.context.stroke();
  177. } else {
  178. this.context.fill();
  179. }
  180. }
  181. // Draw interval ticks
  182. this.context.strokeStyle = this.theme.COLORS.CHART_LABEL_COLOR;
  183. this.context.fillStyle = this.theme.COLORS.CHART_LABEL_COLOR;
  184. let lastIntervalTxt: string | undefined = undefined;
  185. for (let i = 0; i < intervals.length; i++) {
  186. const interval = vec3.fromValues(configView.left, intervals[i], 1);
  187. const text = this.chart.formatter(intervals[i]);
  188. if (text === lastIntervalTxt) {
  189. continue;
  190. }
  191. lastIntervalTxt = text;
  192. vec3.transformMat3(interval, interval, configViewToPhysicalSpace);
  193. if (i === 0) {
  194. this.context.textAlign = 'left';
  195. this.context.fillText(text, left + textOffsetLeft, interval[1]);
  196. this.context.textAlign = 'end';
  197. this.context.fillText(text, right - textOffsetLeft, interval[1]);
  198. continue;
  199. }
  200. this.context.textAlign = 'left';
  201. this.context.beginPath();
  202. this.context.moveTo(left, interval[1]);
  203. this.context.lineTo(left + TICK_WIDTH, interval[1]);
  204. this.context.stroke();
  205. this.context.fillText(text, left + textOffsetLeft, interval[1]);
  206. this.context.textAlign = 'end';
  207. this.context.beginPath();
  208. this.context.moveTo(right, interval[1]);
  209. this.context.lineTo(right - TICK_WIDTH, interval[1]);
  210. this.context.stroke();
  211. this.context.fillText(text, right - textOffsetLeft, interval[1]);
  212. }
  213. if (configSpaceCursorRef.current) {
  214. const cursor = vec3.fromValues(
  215. configSpaceCursorRef.current[0],
  216. configSpaceCursorRef.current[1],
  217. 1
  218. );
  219. vec3.transformMat3(cursor, cursor, configViewToPhysicalSpace);
  220. this.context.beginPath();
  221. this.context.strokeStyle = this.theme.COLORS.CHART_CURSOR_INDICATOR;
  222. this.context.moveTo(cursor[0], origin[1]);
  223. this.context.lineTo(cursor[0], space[1]);
  224. this.context.stroke();
  225. }
  226. }
  227. }