• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2018 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import {CallsiteInfo} from '../common/flamegraph_util';
16import {searchSegment} from '../base/binary_search';
17import {cropText} from '../common/canvas_utils';
18
19interface Node {
20  width: number;
21  x: number;
22  nextXForChildren: number;
23  size: number;
24}
25
26interface CallsiteInfoWidth {
27  callsite: CallsiteInfo;
28  width: number;
29}
30
31// Height of one 'row' on the flame chart including 1px of whitespace
32// below the box.
33const NODE_HEIGHT = 18;
34
35export const FLAMEGRAPH_HOVERED_COLOR = 'hsl(224, 45%, 55%)';
36
37export function findRootSize(data: ReadonlyArray<CallsiteInfo>) {
38  let totalSize = 0;
39  let i = 0;
40  while (i < data.length && data[i].depth === 0) {
41    totalSize += data[i].totalSize;
42    i++;
43  }
44  return totalSize;
45}
46
47export interface NodeRendering {
48  totalSize?: string;
49  selfSize?: string;
50}
51
52export class Flamegraph {
53  private nodeRendering: NodeRendering = {};
54  private flamegraphData: ReadonlyArray<CallsiteInfo>;
55  private highlightSomeNodes = false;
56  private maxDepth = -1;
57  private totalSize = -1;
58  // Initialised on first draw() call
59  private labelCharWidth = 0;
60  private labelFontStyle = '12px Roboto Mono';
61  private rolloverFontStyle = '12px Roboto Condensed';
62  // Key for the map is depth followed by x coordinate - `depth;x`
63  private graphData: Map<string, CallsiteInfoWidth> = new Map();
64  private xStartsPerDepth: Map<number, number[]> = new Map();
65
66  private hoveredX = -1;
67  private hoveredY = -1;
68  private hoveredCallsite?: CallsiteInfo;
69  private clickedCallsite?: CallsiteInfo;
70
71  private startingY = 0;
72
73  constructor(flamegraphData: CallsiteInfo[]) {
74    this.flamegraphData = flamegraphData;
75    this.findMaxDepth();
76  }
77
78  private findMaxDepth() {
79    this.maxDepth = Math.max(
80      ...this.flamegraphData.map((value) => value.depth),
81    );
82  }
83
84  // Instead of highlighting the interesting nodes, we actually want to
85  // de-emphasize the non-highlighted nodes. Returns true if there
86  // are any highlighted nodes in the flamegraph.
87  private highlightingExists() {
88    this.highlightSomeNodes = this.flamegraphData.some((e) => e.highlighted);
89  }
90
91  generateColor(
92    name: string,
93    isGreyedOut = false,
94    highlighted: boolean,
95  ): string {
96    if (isGreyedOut) {
97      return '#d9d9d9';
98    }
99    if (name === 'unknown' || name === 'root') {
100      return '#c0c0c0';
101    }
102    let x = 0;
103    for (let i = 0; i < name.length; i += 1) {
104      x += name.charCodeAt(i) % 64;
105    }
106    x = x % 360;
107    let l = '76';
108    // Make non-highlighted node lighter.
109    if (this.highlightSomeNodes && !highlighted) {
110      l = '90';
111    }
112    return `hsl(${x}deg, 45%, ${l}%)`;
113  }
114
115  // Caller will have to call draw method after updating data to have updated
116  // graph.
117  updateDataIfChanged(
118    nodeRendering: NodeRendering,
119    flamegraphData: ReadonlyArray<CallsiteInfo>,
120    clickedCallsite?: CallsiteInfo,
121  ) {
122    this.nodeRendering = nodeRendering;
123    this.clickedCallsite = clickedCallsite;
124    if (this.flamegraphData === flamegraphData) {
125      return;
126    }
127    this.flamegraphData = flamegraphData;
128    this.clickedCallsite = clickedCallsite;
129    this.findMaxDepth();
130    this.highlightingExists();
131    // Finding total size of roots.
132    this.totalSize = findRootSize(flamegraphData);
133  }
134
135  draw(
136    ctx: CanvasRenderingContext2D,
137    width: number,
138    height: number,
139    x = 0,
140    y = 0,
141    unit = 'B',
142  ) {
143    if (this.flamegraphData === undefined) {
144      return;
145    }
146
147    ctx.font = this.labelFontStyle;
148    ctx.textBaseline = 'middle';
149    if (this.labelCharWidth === 0) {
150      this.labelCharWidth = ctx.measureText('_').width;
151    }
152
153    this.startingY = y;
154
155    // For each node, we use this map to get information about its parent
156    // (total size of it, width and where it starts in graph) so we can
157    // calculate it's own position in graph.
158    const nodesMap = new Map<number, Node>();
159    let currentY = y;
160    nodesMap.set(-1, {width, nextXForChildren: x, size: this.totalSize, x});
161
162    // Initialize data needed for click/hover behavior.
163    this.graphData = new Map();
164    this.xStartsPerDepth = new Map();
165
166    // Draw root node.
167    ctx.fillStyle = this.generateColor('root', false, false);
168    ctx.fillRect(x, currentY, width, NODE_HEIGHT - 1);
169    const text = cropText(
170      `root: ${this.displaySize(
171        this.totalSize,
172        unit,
173        unit === 'B' ? 1024 : 1000,
174      )}`,
175      this.labelCharWidth,
176      width - 2,
177    );
178    ctx.fillStyle = 'black';
179    ctx.fillText(text, x + 5, currentY + (NODE_HEIGHT - 1) / 2);
180    currentY += NODE_HEIGHT;
181
182    // Set style for borders.
183    ctx.strokeStyle = 'white';
184    ctx.lineWidth = 0.5;
185
186    for (let i = 0; i < this.flamegraphData.length; i++) {
187      if (currentY > height) {
188        break;
189      }
190      const value = this.flamegraphData[i];
191      const parentNode = nodesMap.get(value.parentId);
192      if (parentNode === undefined) {
193        continue;
194      }
195
196      const isClicked = this.clickedCallsite !== undefined;
197      const isFullWidth =
198        isClicked && value.depth <= this.clickedCallsite!.depth;
199      const isGreyedOut =
200        isClicked && value.depth < this.clickedCallsite!.depth;
201
202      const parent = value.parentId;
203      const parentSize = parent === -1 ? this.totalSize : parentNode.size;
204      // Calculate node's width based on its proportion in parent.
205      const width =
206        (isFullWidth ? 1 : value.totalSize / parentSize) * parentNode.width;
207
208      const currentX = parentNode.nextXForChildren;
209      currentY = y + NODE_HEIGHT * (value.depth + 1);
210
211      // Draw node.
212      const name = this.getCallsiteName(value);
213      ctx.fillStyle = this.generateColor(name, isGreyedOut, value.highlighted);
214      ctx.fillRect(currentX, currentY, width, NODE_HEIGHT - 1);
215
216      // Set current node's data in map for children to use.
217      nodesMap.set(value.id, {
218        width,
219        nextXForChildren: currentX,
220        size: value.totalSize,
221        x: currentX,
222      });
223      // Update next x coordinate in parent.
224      nodesMap.set(value.parentId, {
225        width: parentNode.width,
226        nextXForChildren: currentX + width,
227        size: parentNode.size,
228        x: parentNode.x,
229      });
230
231      // Draw name.
232      const labelPaddingPx = 5;
233      const maxLabelWidth = width - labelPaddingPx * 2;
234      let text = cropText(name, this.labelCharWidth, maxLabelWidth);
235      // If cropped text and the original text are within 20% we keep the
236      // original text and just squish it a bit.
237      if (text.length * 1.2 > name.length) {
238        text = name;
239      }
240      ctx.fillStyle = 'black';
241      ctx.fillText(
242        text,
243        currentX + labelPaddingPx,
244        currentY + (NODE_HEIGHT - 1) / 2,
245        maxLabelWidth,
246      );
247
248      // Draw border on the right of node.
249      ctx.beginPath();
250      ctx.moveTo(currentX + width, currentY);
251      ctx.lineTo(currentX + width, currentY + NODE_HEIGHT);
252      ctx.stroke();
253
254      // Add this node for recognizing in click/hover.
255      // Map graphData contains one callsite which is on that depth and X
256      // start. Map xStartsPerDepth for each depth contains all X start
257      // coordinates that callsites on that level have.
258      this.graphData.set(`${value.depth};${currentX}`, {
259        callsite: value,
260        width,
261      });
262      const xStarts = this.xStartsPerDepth.get(value.depth);
263      if (xStarts === undefined) {
264        this.xStartsPerDepth.set(value.depth, [currentX]);
265      } else {
266        xStarts.push(currentX);
267      }
268    }
269
270    // Draw the tooltip.
271    if (this.hoveredX > -1 && this.hoveredY > -1 && this.hoveredCallsite) {
272      // Must set these before measureText below.
273      ctx.font = this.rolloverFontStyle;
274      ctx.textBaseline = 'top';
275
276      // Size in px of the border around the text and the edge of the rollover
277      // background.
278      const paddingPx = 8;
279      // Size in px of the x and y offset between the mouse and the top left
280      // corner of the rollover box.
281      const offsetPx = 4;
282
283      const lines: string[] = [];
284
285      let textWidth = this.addToTooltip(
286        this.getCallsiteName(this.hoveredCallsite),
287        width - paddingPx,
288        ctx,
289        lines,
290      );
291      if (this.hoveredCallsite.location != null) {
292        textWidth = Math.max(
293          textWidth,
294          this.addToTooltip(this.hoveredCallsite.location, width, ctx, lines),
295        );
296      }
297      textWidth = Math.max(
298        textWidth,
299        this.addToTooltip(this.hoveredCallsite.mapping, width, ctx, lines),
300      );
301
302      if (this.nodeRendering.totalSize !== undefined) {
303        const percentage =
304          (this.hoveredCallsite.totalSize / this.totalSize) * 100;
305        const totalSizeText = `${
306          this.nodeRendering.totalSize
307        }: ${this.displaySize(
308          this.hoveredCallsite.totalSize,
309          unit,
310          unit === 'B' ? 1024 : 1000,
311        )} (${percentage.toFixed(2)}%)`;
312        textWidth = Math.max(
313          textWidth,
314          this.addToTooltip(totalSizeText, width, ctx, lines),
315        );
316      }
317
318      if (
319        this.nodeRendering.selfSize !== undefined &&
320        this.hoveredCallsite.selfSize > 0
321      ) {
322        const selfPercentage =
323          (this.hoveredCallsite.selfSize / this.totalSize) * 100;
324        const selfSizeText = `${
325          this.nodeRendering.selfSize
326        }: ${this.displaySize(
327          this.hoveredCallsite.selfSize,
328          unit,
329          unit === 'B' ? 1024 : 1000,
330        )} (${selfPercentage.toFixed(2)}%)`;
331        textWidth = Math.max(
332          textWidth,
333          this.addToTooltip(selfSizeText, width, ctx, lines),
334        );
335      }
336
337      // Compute a line height as the bounding box height + 50%:
338      const heightSample = ctx.measureText(
339        'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
340      );
341      const lineHeight = Math.round(
342        heightSample.actualBoundingBoxDescent * 1.5,
343      );
344
345      const rectWidth = textWidth + 2 * paddingPx;
346      const rectHeight = lineHeight * lines.length + 2 * paddingPx;
347
348      let rectXStart = this.hoveredX + offsetPx;
349      let rectYStart = this.hoveredY + offsetPx;
350
351      if (rectXStart + rectWidth > width) {
352        rectXStart = width - rectWidth;
353      }
354
355      if (rectYStart + rectHeight > height) {
356        rectYStart = height - rectHeight;
357      }
358
359      ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
360      ctx.fillRect(rectXStart, rectYStart, rectWidth, rectHeight);
361      ctx.fillStyle = 'hsl(200, 50%, 40%)';
362      ctx.textAlign = 'left';
363      for (let i = 0; i < lines.length; i++) {
364        const line = lines[i];
365        ctx.fillText(
366          line,
367          rectXStart + paddingPx,
368          rectYStart + paddingPx + i * lineHeight,
369        );
370      }
371    }
372  }
373
374  private addToTooltip(
375    text: string,
376    width: number,
377    ctx: CanvasRenderingContext2D,
378    lines: string[],
379  ): number {
380    const lineSplitter: LineSplitter = splitIfTooBig(
381      text,
382      width,
383      ctx.measureText(text).width,
384    );
385    lines.push(...lineSplitter.lines);
386    return lineSplitter.lineWidth;
387  }
388
389  private getCallsiteName(value: CallsiteInfo): string {
390    return value.name === undefined || value.name === ''
391      ? 'unknown'
392      : value.name;
393  }
394
395  private displaySize(totalSize: number, unit: string, step = 1024): string {
396    if (unit === '') return totalSize.toLocaleString();
397    if (totalSize === 0) return `0 ${unit}`;
398    const units = [
399      ['', 1],
400      ['K', step],
401      ['M', Math.pow(step, 2)],
402      ['G', Math.pow(step, 3)],
403    ];
404    let unitsIndex = Math.trunc(Math.log(totalSize) / Math.log(step));
405    unitsIndex = unitsIndex > units.length - 1 ? units.length - 1 : unitsIndex;
406    const result = totalSize / +units[unitsIndex][1];
407    const resultString =
408      totalSize % +units[unitsIndex][1] === 0
409        ? result.toString()
410        : result.toFixed(2);
411    return `${resultString} ${units[unitsIndex][0]}${unit}`;
412  }
413
414  onMouseMove({x, y}: {x: number; y: number}) {
415    this.hoveredX = x;
416    this.hoveredY = y;
417    this.hoveredCallsite = this.findSelectedCallsite(x, y);
418    const isCallsiteSelected = this.hoveredCallsite !== undefined;
419    if (!isCallsiteSelected) {
420      this.onMouseOut();
421    }
422    return isCallsiteSelected;
423  }
424
425  onMouseOut() {
426    this.hoveredX = -1;
427    this.hoveredY = -1;
428    this.hoveredCallsite = undefined;
429  }
430
431  onMouseClick({x, y}: {x: number; y: number}): CallsiteInfo | undefined {
432    const clickedCallsite = this.findSelectedCallsite(x, y);
433    // TODO(b/148596659): Allow to expand [merged] callsites. Currently,
434    // this expands to the biggest of the nodes that were merged, which
435    // is confusing, so we disallow clicking on them.
436    if (clickedCallsite === undefined || clickedCallsite.merged) {
437      return undefined;
438    }
439    return clickedCallsite;
440  }
441
442  private findSelectedCallsite(x: number, y: number): CallsiteInfo | undefined {
443    const depth = Math.trunc((y - this.startingY) / NODE_HEIGHT) - 1; // at 0 is root
444    if (depth >= 0 && this.xStartsPerDepth.has(depth)) {
445      const startX = this.searchSmallest(this.xStartsPerDepth.get(depth)!, x);
446      const result = this.graphData.get(`${depth};${startX}`);
447      if (result !== undefined) {
448        const width = result.width;
449        return startX + width >= x ? result.callsite : undefined;
450      }
451    }
452    return undefined;
453  }
454
455  searchSmallest(haystack: number[], needle: number): number {
456    haystack = haystack.sort((n1, n2) => n1 - n2);
457    const [left] = searchSegment(haystack, needle);
458    return left === -1 ? -1 : haystack[left];
459  }
460
461  getHeight(): number {
462    return this.flamegraphData.length === 0
463      ? 0
464      : (this.maxDepth + 2) * NODE_HEIGHT;
465  }
466}
467
468export interface LineSplitter {
469  lineWidth: number;
470  lines: string[];
471}
472
473export function splitIfTooBig(
474  line: string,
475  width: number,
476  lineWidth: number,
477): LineSplitter {
478  if (line === '') return {lineWidth, lines: []};
479  const lines: string[] = [];
480  const charWidth = lineWidth / line.length;
481  const maxWidth = width - 32;
482  const maxLineLen = Math.trunc(maxWidth / charWidth);
483  while (line.length > 0) {
484    lines.push(line.slice(0, maxLineLen));
485    line = line.slice(maxLineLen);
486  }
487  lineWidth = Math.min(maxLineLen * charWidth, lineWidth);
488  return {lineWidth, lines};
489}
490