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