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