| // Copyright (C) 2024 The Android Open Source Project | |
| // | |
| // Licensed under the Apache License, Version 2.0 (the "License"); | |
| // you may not use this file except in compliance with the License. | |
| // You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, software | |
| // distributed under the License is distributed on an "AS IS" BASIS, | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| // See the License for the specific language governing permissions and | |
| // limitations under the License. | |
| import './flamegraph.scss'; | |
| import m from 'mithril'; | |
| import {ensureExists, assertTrue, assertUnreachable} from '../base/assert'; | |
| import {Monitor} from '../base/monitor'; | |
| import {Button, ButtonBar} from './button'; | |
| import {copyToClipboard} from '../base/clipboard'; | |
| import {EmptyState} from './empty_state'; | |
| import type {ExportFormat} from './export_button'; | |
| import { | |
| formatAsTSV, | |
| formatAsJSON, | |
| formatAsMarkdown, | |
| } from '../base/export_formatters'; | |
| import {Popup, PopupPosition} from './popup'; | |
| import {Spinner} from './spinner'; | |
| import type {Rect2D, Size2D} from '../base/geom'; | |
| import { | |
| VirtualOverlayCanvas, | |
| type VirtualOverlayCanvasApi, | |
| } from './virtual_overlay_canvas'; | |
| import {MenuItem, type MenuItemAttrs, PopupMenu} from './menu'; | |
| import {type Color, HSLColor} from '../base/color'; | |
| import {hash} from '../base/hash'; | |
| import {escapeRegex} from './flamegraph_regex'; | |
| import type {MithrilEvent} from '../base/mithril_utils'; | |
| import {Icons} from '../base/semantic_icons'; | |
| import { | |
| type ActionCategory, | |
| FILTER_TYPES, | |
| type FilterType, | |
| type TreeExplorerData, | |
| type TreeExplorerFilter, | |
| type TreeExplorerMetric, | |
| type TreeExplorerNode, | |
| type TreeExplorerOptionalAction, | |
| type TreeExplorerPropertyDefinition, | |
| type TreeExplorerState, | |
| addFilter, | |
| displayPercentage, | |
| displaySize, | |
| getUnitDisplayName, | |
| metricId, | |
| } from './tree_explorer'; | |
| const LABEL_FONT_STYLE = '12px Roboto'; | |
| const NODE_HEIGHT = 20; | |
| const MIN_PIXEL_DISPLAYED = 3; | |
| const LABEL_PADDING_PX = 5; | |
| const LABEL_MIN_WIDTH_FOR_TEXT_PX = 5; | |
| const PADDING_NODE_COUNT = 8; | |
| interface BaseSource { | |
| readonly queryXStart: number; | |
| readonly queryXEnd: number; | |
| readonly type: 'ABOVE_ROOT' | 'BELOW_ROOT' | 'ROOT'; | |
| } | |
| interface MergedSource extends BaseSource { | |
| readonly kind: 'MERGED'; | |
| } | |
| interface RootSource extends BaseSource { | |
| readonly kind: 'ROOT'; | |
| } | |
| interface NodeSource extends BaseSource { | |
| readonly kind: 'NODE'; | |
| readonly queryIdx: number; | |
| } | |
| type Source = MergedSource | NodeSource | RootSource; | |
| interface RenderNode { | |
| readonly x: number; | |
| readonly y: number; | |
| readonly width: number; | |
| readonly source: Source; | |
| readonly state: 'NORMAL' | 'PARTIAL' | 'SELECTED'; | |
| } | |
| interface ZoomRegion { | |
| readonly queryXStart: number; | |
| readonly queryXEnd: number; | |
| readonly type: 'ABOVE_ROOT' | 'BELOW_ROOT' | 'ROOT'; | |
| } | |
| const CATEGORY_LABELS: Record<ActionCategory, string> = { | |
| FOCUS: 'Focus', | |
| FILTER: 'Filter', | |
| DRILL: 'Drill down', | |
| COPY: 'Copy', | |
| }; | |
| const CATEGORY_ORDER: ReadonlyArray<ActionCategory> = [ | |
| 'FOCUS', | |
| 'FILTER', | |
| 'DRILL', | |
| 'COPY', | |
| ]; | |
| const CATEGORY_ICONS: Record<ActionCategory, string> = { | |
| FOCUS: 'center_focus_weak', | |
| FILTER: 'filter_list', | |
| DRILL: 'open_in_new', | |
| COPY: 'content_copy', | |
| }; | |
| interface NodeAction { | |
| readonly label: string; | |
| readonly icon: string; | |
| readonly description?: m.Children; | |
| readonly category: ActionCategory; | |
| execute(): void; | |
| } | |
| export interface FlamegraphAttrs { | |
| readonly metrics: ReadonlyArray<TreeExplorerMetric>; | |
| readonly state: TreeExplorerState; | |
| readonly data: TreeExplorerData | undefined; | |
| // Nodes whose name matches are drawn with a highlighted border. Owned by | |
| // the caller so the (separate) filter bar can drive it. | |
| readonly highlightRegex?: RegExp; | |
| readonly onStateChange: (state: TreeExplorerState) => void; | |
| } | |
| /* | |
| * Widget visualizing "tree-like" data structures using an interactive | |
| * flamegraph visualization: just the canvas and its tooltip. The filtering | |
| * bar lives in the separate TreeExplorerFilterBar widget; most callers should | |
| * use TreeExplorerPanel (in components/) which composes the two and fetches | |
| * the data. | |
| * | |
| * Note that it's valid to pass "undefined" as the data: this will cause a | |
| * loading container to be shown. | |
| */ | |
| export class Flamegraph implements m.ClassComponent<FlamegraphAttrs> { | |
| private attrs: FlamegraphAttrs; | |
| private dataChangeMonitor = new Monitor([() => this.attrs.data]); | |
| private zoomRegion?: ZoomRegion; | |
| private canvasApi?: VirtualOverlayCanvasApi; | |
| private pendingScrollToY?: number; | |
| private renderNodesMonitor = new Monitor([ | |
| () => this.attrs.data, | |
| () => this.canvasWidth, | |
| () => this.zoomRegion, | |
| ]); | |
| private renderNodes?: ReadonlyArray<RenderNode>; | |
| private tooltipPos?: { | |
| x: number; | |
| y: number; | |
| source: Source; | |
| state: 'HOVER' | 'CLICK' | 'DECLICK'; | |
| }; | |
| private lastClickedNode?: RenderNode; | |
| // Track hovered node by index to avoid redraws when mouse moves within same node. | |
| // We also keep hoveredX/Y so we can re-find the node after render nodes change. | |
| private hoveredNodeIdx?: number; | |
| private hoveredX?: number; | |
| private hoveredY?: number; | |
| private canvasWidth = 0; | |
| private labelCharWidth = 0; | |
| private viewportRect?: Rect2D; | |
| private lastPopupVisible = false; | |
| constructor({attrs}: m.Vnode<FlamegraphAttrs, {}>) { | |
| this.attrs = attrs; | |
| } | |
| oncreate() { | |
| this.flushPendingScroll(); | |
| } | |
| onupdate() { | |
| this.flushPendingScroll(); | |
| } | |
| private flushPendingScroll() { | |
| if (this.pendingScrollToY === undefined || this.canvasApi === undefined) { | |
| return; | |
| } | |
| const requestedY = this.pendingScrollToY; | |
| const {y: appliedY} = this.canvasApi.scrollTo({y: requestedY}); | |
| if (requestedY !== Number.MAX_SAFE_INTEGER && appliedY !== requestedY) { | |
| // Keep shallow graphs aligned to whole rows when the target is unreachable. | |
| this.canvasApi.scrollTo({y: 0}); | |
| } | |
| this.pendingScrollToY = undefined; | |
| } | |
| view({attrs}: m.Vnode<FlamegraphAttrs, this>): void | m.Children { | |
| this.attrs = attrs; | |
| if (this.dataChangeMonitor.ifStateChanged()) { | |
| this.zoomRegion = undefined; | |
| this.lastClickedNode = undefined; | |
| this.tooltipPos = undefined; | |
| // Auto-scroll so the root (depth 0) is visible. In TOP_DOWN the root | |
| // sits at the top of the canvas; in BOTTOM_UP it sits near the bottom | |
| // (only callers/leaves above it); in PIVOT it sits somewhere in the | |
| // middle with callers above and callees below. | |
| if (attrs.data !== undefined) { | |
| if (attrs.state.view.kind === 'BOTTOM_UP') { | |
| // Large value — the browser clamps to scrollHeight - clientHeight. | |
| this.pendingScrollToY = Number.MAX_SAFE_INTEGER; | |
| } else { | |
| const rootY = -attrs.data.minDepth * NODE_HEIGHT; | |
| this.pendingScrollToY = Math.max(0, rootY - NODE_HEIGHT); | |
| } | |
| } | |
| } | |
| if (attrs.data === undefined) { | |
| this.canvasApi = undefined; | |
| return m( | |
| '.pf-flamegraph', | |
| m( | |
| '.loading-container', | |
| m( | |
| EmptyState, | |
| { | |
| icon: 'bar_chart', | |
| title: 'Computing graph ...', | |
| className: 'flamegraph-loading', | |
| }, | |
| m(Spinner, {easing: true}), | |
| ), | |
| ), | |
| ); | |
| } | |
| const {minDepth, maxDepth} = attrs.data; | |
| const canvasHeight = | |
| Math.max(maxDepth - minDepth + PADDING_NODE_COUNT, PADDING_NODE_COUNT) * | |
| NODE_HEIGHT; | |
| const hoveredNode = | |
| this.hoveredNodeIdx !== undefined | |
| ? this.renderNodes?.[this.hoveredNodeIdx] | |
| : undefined; | |
| return m( | |
| '.pf-flamegraph', | |
| m( | |
| VirtualOverlayCanvas, | |
| { | |
| className: 'pf-virtual-canvas', | |
| overflowX: 'hidden', | |
| overflowY: 'auto', | |
| onMount: (api) => { | |
| this.canvasApi = api; | |
| this.flushPendingScroll(); | |
| }, | |
| onscroll: (e: MithrilEvent<Event>) => { | |
| // Only redraw if popup visibility would change | |
| if (!this.tooltipPos) { | |
| e.redraw = false; | |
| return; | |
| } | |
| const target = e.target as HTMLElement; | |
| const scrollTop = target.scrollTop; | |
| const clientHeight = target.clientHeight; | |
| const tooltipY = this.tooltipPos.y; | |
| const nowVisible = | |
| tooltipY >= scrollTop && tooltipY <= scrollTop + clientHeight; | |
| if (nowVisible === this.lastPopupVisible) { | |
| e.redraw = false; | |
| } | |
| }, | |
| onCanvasRedraw: ({ | |
| ctx, | |
| virtualCanvasSize, | |
| canvasRect, | |
| viewportRect, | |
| }) => { | |
| this.drawCanvas(ctx, virtualCanvasSize, canvasRect, viewportRect); | |
| }, | |
| }, | |
| m( | |
| 'div', | |
| { | |
| style: { | |
| height: `${canvasHeight}px`, | |
| cursor: hoveredNode === undefined ? 'default' : 'pointer', | |
| }, | |
| onmousemove: (e: MithrilEvent<MouseEvent>) => { | |
| const {offsetX, offsetY} = e; | |
| this.hoveredX = offsetX; | |
| this.hoveredY = offsetY; | |
| const nodeIdx = this.renderNodes?.findIndex((n) => | |
| isIntersecting(offsetX, offsetY, n), | |
| ); | |
| const newHoveredIdx = | |
| nodeIdx !== undefined && nodeIdx !== -1 ? nodeIdx : undefined; | |
| if (newHoveredIdx === this.hoveredNodeIdx) { | |
| e.redraw = false; | |
| return; | |
| } | |
| this.hoveredNodeIdx = newHoveredIdx; | |
| if (this.tooltipPos?.state === 'CLICK') { | |
| return; | |
| } | |
| if (newHoveredIdx === undefined) { | |
| this.tooltipPos = undefined; | |
| return; | |
| } | |
| const renderNode = this.renderNodes![newHoveredIdx]; | |
| this.tooltipPos = { | |
| x: offsetX, | |
| y: renderNode.y, | |
| source: renderNode.source, | |
| state: 'HOVER', | |
| }; | |
| }, | |
| onmouseout: () => { | |
| this.hoveredNodeIdx = undefined; | |
| this.hoveredX = undefined; | |
| this.hoveredY = undefined; | |
| if ( | |
| this.tooltipPos?.state === 'HOVER' || | |
| this.tooltipPos?.state === 'DECLICK' | |
| ) { | |
| this.tooltipPos = undefined; | |
| } | |
| }, | |
| onclick: ({offsetX, offsetY}: MouseEvent) => { | |
| const renderNode = this.renderNodes?.find((n) => | |
| isIntersecting(offsetX, offsetY, n), | |
| ); | |
| this.lastClickedNode = renderNode; | |
| if (renderNode === undefined) { | |
| this.tooltipPos = undefined; | |
| } else if ( | |
| isIntersecting( | |
| this.tooltipPos?.x, | |
| this.tooltipPos?.y, | |
| renderNode, | |
| ) | |
| ) { | |
| this.tooltipPos!.state = | |
| this.tooltipPos?.state === 'CLICK' ? 'DECLICK' : 'CLICK'; | |
| } else { | |
| this.tooltipPos = { | |
| x: offsetX, | |
| y: renderNode.y, | |
| source: renderNode.source, | |
| state: 'CLICK', | |
| }; | |
| } | |
| }, | |
| ondblclick: ({offsetX, offsetY}: MouseEvent) => { | |
| const renderNode = this.renderNodes?.find((n) => | |
| isIntersecting(offsetX, offsetY, n), | |
| ); | |
| if (renderNode?.source.kind === 'MERGED') { | |
| return; | |
| } | |
| this.zoomRegion = renderNode?.source; | |
| }, | |
| }, | |
| (() => { | |
| const popupVisible = | |
| this.isPopupAnchorVisible() && | |
| (this.tooltipPos?.state === 'HOVER' || | |
| this.tooltipPos?.state === 'CLICK'); | |
| this.lastPopupVisible = popupVisible; | |
| return m( | |
| Popup, | |
| { | |
| trigger: m('.popup-anchor', { | |
| style: { | |
| left: this.tooltipPos?.x + 'px', | |
| top: this.tooltipPos?.y + 'px', | |
| }, | |
| }), | |
| fitContent: true, | |
| position: PopupPosition.Right, | |
| isOpen: popupVisible, | |
| className: 'pf-flamegraph-tooltip-popup', | |
| offset: NODE_HEIGHT, | |
| }, | |
| this.renderTooltip(), | |
| ); | |
| })(), | |
| ), | |
| ), | |
| ); | |
| } | |
| private drawCanvas( | |
| ctx: CanvasRenderingContext2D, | |
| size: Size2D, | |
| canvasRect: Rect2D, | |
| viewportRect: Rect2D, | |
| ) { | |
| this.viewportRect = viewportRect; | |
| this.canvasWidth = size.width; | |
| if (this.renderNodesMonitor.ifStateChanged()) { | |
| if (this.attrs.data === undefined) { | |
| this.renderNodes = undefined; | |
| this.lastClickedNode = undefined; | |
| } else { | |
| this.renderNodes = computeRenderNodes( | |
| this.attrs.data, | |
| this.zoomRegion ?? { | |
| queryXStart: 0, | |
| queryXEnd: this.attrs.data.allRootsCumulativeValue, | |
| type: 'ROOT', | |
| }, | |
| size.width, | |
| ); | |
| this.lastClickedNode = this.renderNodes?.find((n) => | |
| isIntersecting(this.lastClickedNode?.x, this.lastClickedNode?.y, n), | |
| ); | |
| } | |
| this.tooltipPos = undefined; | |
| // Re-find hovered node using stored coordinates | |
| const nodeIdx = this.renderNodes?.findIndex((n) => | |
| isIntersecting(this.hoveredX, this.hoveredY, n), | |
| ); | |
| this.hoveredNodeIdx = | |
| nodeIdx !== undefined && nodeIdx !== -1 ? nodeIdx : undefined; | |
| } | |
| if (this.attrs.data === undefined || this.renderNodes === undefined) { | |
| return; | |
| } | |
| const yStart = canvasRect.top; | |
| const yEnd = canvasRect.bottom; | |
| const {allRootsCumulativeValue, unfilteredCumulativeValue, nodes} = | |
| this.attrs.data; | |
| const unit = ensureExists(this.selectedMetric).unit; | |
| ctx.font = LABEL_FONT_STYLE; | |
| ctx.textBaseline = 'middle'; | |
| ctx.strokeStyle = 'white'; | |
| ctx.lineWidth = 0.5; | |
| if (this.labelCharWidth === 0) { | |
| this.labelCharWidth = ctx.measureText('_').width; | |
| } | |
| const highlightColor = getComputedStyle(ctx.canvas) | |
| .getPropertyValue('--pf-color-accent') | |
| .trim(); | |
| for (let i = 0; i < this.renderNodes.length; i++) { | |
| const node = this.renderNodes[i]; | |
| const {x, y, width: width, source, state} = node; | |
| if (y + NODE_HEIGHT <= yStart || y >= yEnd) { | |
| continue; | |
| } | |
| const hover = isIntersecting(this.hoveredX, this.hoveredY, node); | |
| let name: string; | |
| let colorScheme; | |
| if (source.kind === 'ROOT') { | |
| const val = displaySize(allRootsCumulativeValue, unit); | |
| const percent = displayPercentage( | |
| allRootsCumulativeValue, | |
| unfilteredCumulativeValue, | |
| ); | |
| name = `root: ${val} (${percent})`; | |
| colorScheme = getFlamegraphColorScheme('root', state === 'PARTIAL'); | |
| } else if (source.kind === 'MERGED') { | |
| name = '(merged)'; | |
| colorScheme = getFlamegraphColorScheme(name, state === 'PARTIAL'); | |
| } else { | |
| name = nodes[source.queryIdx].name; | |
| colorScheme = getFlamegraphColorScheme(name, state === 'PARTIAL'); | |
| } | |
| const highlighted = | |
| source.kind === 'NODE' && | |
| this.attrs.highlightRegex?.test(name) === true; | |
| const bgColor = hover ? colorScheme.variant : colorScheme.base; | |
| const textColor = hover ? colorScheme.textVariant : colorScheme.textBase; | |
| ctx.fillStyle = bgColor.cssString; | |
| ctx.fillRect(x, y, width - 1, NODE_HEIGHT - 1); | |
| // Render marker | |
| const MARKER_SIZE = 3; | |
| const MARKER_LEFT_MARGIN = 2; | |
| const MIN_WIDTH_FOR_MARKER = 15; // Don't show marker on very small nodes | |
| const hasMarker = | |
| source.kind === 'NODE' && | |
| nodes[source.queryIdx].marker !== undefined && | |
| width >= MIN_WIDTH_FOR_MARKER; | |
| if (hasMarker) { | |
| ctx.fillStyle = textColor.cssString; | |
| const markerX = x + MARKER_LEFT_MARGIN; | |
| const markerY = y + 2; // Position at top of node with small margin | |
| ctx.fillRect(markerX, markerY, MARKER_SIZE, MARKER_SIZE); | |
| } | |
| // Text positioning - no need to reserve space since marker is in top corner | |
| const widthNoPadding = width - LABEL_PADDING_PX * 2; | |
| if (widthNoPadding >= LABEL_MIN_WIDTH_FOR_TEXT_PX) { | |
| ctx.fillStyle = textColor.cssString; | |
| ctx.fillText( | |
| name.substring(0, widthNoPadding / this.labelCharWidth), | |
| x + LABEL_PADDING_PX, | |
| y + (NODE_HEIGHT - 1) / 2, | |
| widthNoPadding, | |
| ); | |
| } | |
| if (highlighted) { | |
| const highlightWidth = Math.max(0, width - 3); | |
| const highlightHeight = NODE_HEIGHT - 3; | |
| ctx.fillStyle = highlightColor || textColor.cssString; | |
| ctx.fillRect(x + 1, y + 1, highlightWidth, 2); | |
| ctx.fillRect(x + 1, y + NODE_HEIGHT - 3, highlightWidth, 2); | |
| ctx.fillRect(x + 1, y + 1, 2, highlightHeight); | |
| ctx.fillRect(x + width - 3, y + 1, 2, highlightHeight); | |
| } | |
| if (this.lastClickedNode?.x === x && this.lastClickedNode?.y === y) { | |
| ctx.strokeStyle = 'blue'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(x, y); | |
| ctx.lineTo(x + width, y); | |
| ctx.lineTo(x + width, y + NODE_HEIGHT - 1); | |
| ctx.lineTo(x, y + NODE_HEIGHT - 1); | |
| ctx.lineTo(x, y); | |
| ctx.stroke(); | |
| ctx.strokeStyle = 'white'; | |
| ctx.lineWidth = 0.5; | |
| } | |
| } | |
| } | |
| private isPopupAnchorVisible(): boolean { | |
| if (!this.tooltipPos || !this.viewportRect) { | |
| return false; | |
| } | |
| const {y} = this.tooltipPos; | |
| return y >= this.viewportRect.top && y <= this.viewportRect.bottom; | |
| } | |
| private renderTooltip() { | |
| if (this.tooltipPos === undefined) { | |
| return undefined; | |
| } | |
| const {source} = this.tooltipPos; | |
| if (source.kind === 'MERGED') { | |
| return m( | |
| 'div', | |
| m('.tooltip-bold-text', '(merged)'), | |
| m('.tooltip-text', 'Nodes too small to show, please use filters'), | |
| ); | |
| } | |
| const { | |
| nodes, | |
| allRootsCumulativeValue, | |
| unfilteredCumulativeValue, | |
| nodeActions, | |
| rootActions, | |
| } = ensureExists(this.attrs.data); | |
| const {unit, nameColumnLabel} = ensureExists(this.selectedMetric); | |
| if (source.kind === 'ROOT') { | |
| const val = displaySize(allRootsCumulativeValue, unit); | |
| const percent = displayPercentage( | |
| allRootsCumulativeValue, | |
| unfilteredCumulativeValue, | |
| ); | |
| return m( | |
| 'div', | |
| m('.tooltip-bold-text', 'root'), | |
| m( | |
| '.tooltip-text-line', | |
| m('.tooltip-bold-text', 'Cumulative:'), | |
| m('.tooltip-text', `${val}, ${percent}`), | |
| this.renderActionsMenu(rootActions, new Map()), | |
| ), | |
| ); | |
| } | |
| const {queryIdx} = source; | |
| const { | |
| name, | |
| cumulativeValue, | |
| selfValue, | |
| parentCumulativeValue, | |
| properties, | |
| marker, | |
| } = nodes[queryIdx]; | |
| const percent = displayPercentage( | |
| cumulativeValue, | |
| unfilteredCumulativeValue, | |
| ); | |
| const selfPercent = displayPercentage(selfValue, unfilteredCumulativeValue); | |
| let percentText = `all: ${percent}`; | |
| let selfPercentText = `all: ${selfPercent}`; | |
| if (parentCumulativeValue !== undefined) { | |
| const parentPercent = displayPercentage( | |
| cumulativeValue, | |
| parentCumulativeValue, | |
| ); | |
| percentText += `, parent: ${parentPercent}`; | |
| const parentSelfPercent = displayPercentage( | |
| selfValue, | |
| parentCumulativeValue, | |
| ); | |
| selfPercentText += `, parent: ${parentSelfPercent}`; | |
| } | |
| const nameLabel = nameColumnLabel ?? 'Name'; | |
| return m( | |
| 'div', | |
| // Show marker at the top of the tooltip | |
| marker && | |
| m('.tooltip-text-line', m('.tooltip-marker-text', `â– ${marker}`)), | |
| m( | |
| '.tooltip-text-line', | |
| m('.tooltip-bold-text', `${nameLabel}:`), | |
| m('.tooltip-text', name), | |
| ), | |
| m( | |
| '.tooltip-text-line', | |
| m('.tooltip-bold-text', 'Cumulative:'), | |
| m( | |
| '.tooltip-text', | |
| `${displaySize(cumulativeValue, unit)} (${percentText})`, | |
| ), | |
| ), | |
| m( | |
| '.tooltip-text-line', | |
| m('.tooltip-bold-text', 'Self:'), | |
| m( | |
| '.tooltip-text', | |
| `${displaySize(selfValue, unit)} (${selfPercentText})`, | |
| ), | |
| ), | |
| Array.from(properties, ([_, value]) => { | |
| if (value.isVisible) { | |
| return m( | |
| '.tooltip-text-line', | |
| m('.tooltip-bold-text', value.displayName + ':'), | |
| m('.tooltip-text', value.value), | |
| ); | |
| } | |
| return null; | |
| }), | |
| this.renderNodeActionBar(source, name, nodes[queryIdx], nodeActions), | |
| ); | |
| } | |
| // One dropdown per category. Built-ins come from buildNodeActions; flat | |
| // embedder actions slot into their declared category, nested/disabled ones | |
| // keep the renderMenuItem path under Drill down. | |
| private renderNodeActionBar( | |
| source: NodeSource, | |
| name: string, | |
| node: TreeExplorerNode, | |
| nodeActions: ReadonlyArray<TreeExplorerOptionalAction>, | |
| ) { | |
| const {properties} = node; | |
| const builtIn = this.buildNodeActions(source, name, node); | |
| const isFlat = (a: TreeExplorerOptionalAction) => | |
| a.execute !== undefined && | |
| (a.subActions === undefined || a.subActions.length === 0); | |
| const embedderFlat: NodeAction[] = nodeActions.filter(isFlat).map((a) => ({ | |
| label: a.name, | |
| icon: a.icon ?? 'open_in_new', | |
| description: a.description, | |
| category: a.category ?? 'DRILL', | |
| execute: () => { | |
| a.execute!({ | |
| properties: this.createReducedProperties(properties), | |
| node, | |
| }); | |
| this.tooltipPos = undefined; | |
| }, | |
| })); | |
| const embedderComplex = nodeActions | |
| .filter((a) => !isFlat(a)) | |
| .map((a) => this.renderMenuItem(a, properties, node)); | |
| const actions = [...builtIn, ...embedderFlat]; | |
| return m( | |
| ButtonBar, | |
| {className: 'pf-flamegraph-action-bar'}, | |
| CATEGORY_ORDER.map((cat) => { | |
| const items = actions.filter((a) => a.category === cat); | |
| const extra = cat === 'DRILL' ? embedderComplex : []; | |
| if (items.length === 0 && extra.length === 0) return null; | |
| return m( | |
| PopupMenu, | |
| { | |
| trigger: m(Button, { | |
| label: CATEGORY_LABELS[cat], | |
| icon: CATEGORY_ICONS[cat], | |
| rightIcon: 'arrow_drop_down', | |
| compact: true, | |
| }), | |
| position: PopupPosition.Bottom, | |
| className: 'pf-popup-menu pf-flamegraph-action-menu', | |
| }, | |
| items.map((a) => this.renderNodeActionItem(a)), | |
| extra, | |
| ); | |
| }), | |
| ); | |
| } | |
| private renderNodeActionItem(a: NodeAction): m.Children { | |
| return m(MenuItem, { | |
| icon: a.icon, | |
| label: this.actionItemLabel(a.label, a.description), | |
| onclick: a.execute, | |
| }); | |
| } | |
| // Two-line menu label: name on top, muted description beneath. A name with no | |
| // description renders as a plain single line. | |
| private actionItemLabel( | |
| label: m.Children, | |
| description?: m.Children, | |
| ): m.Children { | |
| if (description == null) return label; | |
| return m( | |
| '.pf-flamegraph-action', | |
| m('.pf-flamegraph-action__title', label), | |
| m('.pf-flamegraph-action__desc', description), | |
| ); | |
| } | |
| private buildNodeActions( | |
| source: NodeSource, | |
| name: string, | |
| node: TreeExplorerNode, | |
| ): NodeAction[] { | |
| const applyState = (state: TreeExplorerState) => { | |
| this.attrs.onStateChange(state); | |
| this.tooltipPos = undefined; | |
| }; | |
| const addF = (kind: TreeExplorerFilter['kind'], filter: string) => | |
| applyState(addFilter(this.attrs.state, {kind, filter})); | |
| // Match this exact name: an anchored regex over the escaped literal name, | |
| // wrapped in `/…/` so the filter bar reads it as a regex. | |
| const exactNameRegex = `/^${escapeRegex(name)}$/`; | |
| const ft = (v: FilterType) => | |
| ensureExists(FILTER_TYPES.find((o) => o.value === v)); | |
| const filterAction = (v: FilterType, execute: () => void): NodeAction => { | |
| const o = ft(v); | |
| return { | |
| label: o.friendlyLabel, | |
| icon: o.icon, | |
| category: o.category, | |
| description: [ | |
| o.description, | |
| o.aka !== undefined && [ | |
| ' ', | |
| m( | |
| 'span.pf-flamegraph-action__aka', | |
| `Also called "${o.aka}" in other profilers.`, | |
| ), | |
| ], | |
| ], | |
| execute, | |
| }; | |
| }; | |
| return [ | |
| { | |
| label: 'Zoom in', | |
| icon: 'zoom_in', | |
| category: 'FOCUS', | |
| description: | |
| 'Enlarge this branch. Nothing is removed, so you can zoom out anytime.', | |
| execute: () => { | |
| this.zoomRegion = source; | |
| }, | |
| }, | |
| { | |
| label: 'Show from this frame', | |
| icon: 'center_focus_strong', | |
| category: 'FOCUS', | |
| description: | |
| 'Re-root at this frame and show its descendants, dropping ancestors.', | |
| execute: () => | |
| applyState({ | |
| ...this.attrs.state, | |
| view: { | |
| kind: 'FROM_FRAME', | |
| pattern: exactNameRegex, | |
| displayLabel: name, | |
| }, | |
| }), | |
| }, | |
| { | |
| label: 'Pivot on this frame', | |
| icon: 'account_tree', | |
| category: 'FOCUS', | |
| description: | |
| 'Re-root at this frame with callers above and callees below.', | |
| execute: () => | |
| applyState({ | |
| ...this.attrs.state, | |
| view: { | |
| kind: 'PIVOT', | |
| pivot: exactNameRegex, | |
| displayLabel: name, | |
| }, | |
| }), | |
| }, | |
| filterAction('SHOW_STACK', () => addF('SHOW_STACK', exactNameRegex)), | |
| filterAction('HIDE_STACK', () => addF('HIDE_STACK', exactNameRegex)), | |
| filterAction('HIDE_FRAME', () => addF('HIDE_FRAME', exactNameRegex)), | |
| { | |
| label: 'Copy stack', | |
| icon: Icons.Copy, | |
| category: 'COPY', | |
| description: 'Copy this stack as text.', | |
| execute: () => copyToClipboard(this.buildStackString(node, false)), | |
| }, | |
| { | |
| label: 'Copy stack with details', | |
| icon: Icons.Copy, | |
| category: 'COPY', | |
| description: 'Copy this stack with per-frame metrics and columns.', | |
| execute: () => copyToClipboard(this.buildStackString(node, true)), | |
| }, | |
| ]; | |
| } | |
| private get selectedMetric() { | |
| return this.attrs.metrics.find( | |
| (x) => metricId(x) === this.attrs.state.selectedMetricId, | |
| ); | |
| } | |
| // Root-only actions menu; node actions go through renderNodeActionBar. | |
| private renderActionsMenu( | |
| actions: ReadonlyArray<TreeExplorerOptionalAction>, | |
| properties: ReadonlyMap<string, TreeExplorerPropertyDefinition>, | |
| ) { | |
| if (actions.length === 0) { | |
| return null; | |
| } | |
| return m( | |
| PopupMenu, | |
| { | |
| trigger: m(Button, { | |
| icon: 'menu', | |
| compact: true, | |
| }), | |
| position: PopupPosition.Bottom, | |
| }, | |
| actions.map((action) => this.renderMenuItem(action, properties)), | |
| ); | |
| } | |
| private renderMenuItem( | |
| action: TreeExplorerOptionalAction, | |
| properties: ReadonlyMap<string, TreeExplorerPropertyDefinition>, | |
| node?: TreeExplorerNode, | |
| ): m.Vnode<MenuItemAttrs> { | |
| if (action.subActions !== undefined && action.subActions.length > 0) { | |
| return this.renderParentMenuItem( | |
| action, | |
| action.subActions, | |
| properties, | |
| node, | |
| ); | |
| } else if (action.execute) { | |
| return this.renderExecutableMenuItem(action, properties, node); | |
| } else { | |
| return this.renderDisabledMenuItem(action); | |
| } | |
| } | |
| private renderParentMenuItem( | |
| action: TreeExplorerOptionalAction, | |
| subActions: TreeExplorerOptionalAction[], | |
| properties: ReadonlyMap<string, TreeExplorerPropertyDefinition>, | |
| node?: TreeExplorerNode, | |
| ): m.Vnode<MenuItemAttrs> { | |
| return m( | |
| MenuItem, | |
| { | |
| label: this.actionItemLabel(action.name, action.description), | |
| icon: action.icon, | |
| // No onclick handler for parent menu items | |
| }, | |
| // Directly render sub-actions as children of the MenuItem | |
| subActions.map((subAction) => | |
| this.renderMenuItem(subAction, properties, node), | |
| ), | |
| ); | |
| } | |
| private renderExecutableMenuItem( | |
| action: TreeExplorerOptionalAction, | |
| properties: ReadonlyMap<string, TreeExplorerPropertyDefinition>, | |
| node?: TreeExplorerNode, | |
| ): m.Vnode<MenuItemAttrs> { | |
| return m(MenuItem, { | |
| label: this.actionItemLabel(action.name, action.description), | |
| icon: action.icon, | |
| onclick: () => { | |
| action.execute!({ | |
| properties: this.createReducedProperties(properties), | |
| node, | |
| }); | |
| this.tooltipPos = undefined; // Close tooltip after action | |
| }, | |
| }); | |
| } | |
| private renderDisabledMenuItem( | |
| action: TreeExplorerOptionalAction, | |
| ): m.Vnode<MenuItemAttrs> { | |
| return m(MenuItem, { | |
| label: this.actionItemLabel(action.name, action.description), | |
| icon: action.icon, | |
| disabled: true, | |
| }); | |
| } | |
| private buildStackString( | |
| node: TreeExplorerNode, | |
| withDetails: boolean, | |
| ): string { | |
| const {nodes, unfilteredCumulativeValue} = ensureExists(this.attrs.data); | |
| const metric = ensureExists(this.selectedMetric); | |
| const view = this.attrs.state.view; | |
| // Walk via parentId for all modes. Reverse for TOP_DOWN and PIVOT below. | |
| const stack: TreeExplorerNode[] = []; | |
| let currentId = node.id; | |
| while (currentId !== -1) { | |
| const current = ensureExists(nodes.find((n) => n.id === currentId)); | |
| stack.push(current); | |
| currentId = current.parentId; | |
| } | |
| const shouldReverse = | |
| view.kind === 'TOP_DOWN' || | |
| view.kind === 'FROM_FRAME' || | |
| (view.kind === 'PIVOT' && node.depth > 0); | |
| if (shouldReverse) { | |
| stack.reverse(); | |
| } | |
| if (!withDetails) { | |
| return stack.map((n) => n.name).join('\n'); | |
| } | |
| // Collect all unique property keys, separated by aggregatable status | |
| const unaggKeys: string[] = []; | |
| const aggKeys: string[] = []; | |
| for (const entry of stack) { | |
| for (const [key, prop] of entry.properties) { | |
| if (prop.isAggregatable) { | |
| if (!aggKeys.includes(key)) { | |
| aggKeys.push(key); | |
| } | |
| } else { | |
| if (!unaggKeys.includes(key)) { | |
| unaggKeys.push(key); | |
| } | |
| } | |
| } | |
| } | |
| // Helper to get display name for a property key | |
| const getDisplayName = (key: string): string => { | |
| for (const entry of stack) { | |
| const prop = entry.properties.get(key); | |
| if (prop !== undefined) { | |
| return prop.displayName; | |
| } | |
| } | |
| return key; | |
| }; | |
| // Build header: Name | Non-agg props | Cumulative | Self | Agg props | |
| const nameLabel = metric.nameColumnLabel ?? 'Name'; | |
| const unitDisplay = getUnitDisplayName(metric.unit); | |
| const headers = [nameLabel]; | |
| for (const key of unaggKeys) { | |
| headers.push(getDisplayName(key)); | |
| } | |
| headers.push( | |
| `Cumulative ${metric.name} (${unitDisplay})`, | |
| `Self ${metric.name} (${unitDisplay})`, | |
| ); | |
| for (const key of aggKeys) { | |
| headers.push(getDisplayName(key)); | |
| } | |
| // Format as markdown table | |
| const lines: string[] = []; | |
| lines.push('| ' + headers.join(' | ') + ' |'); | |
| lines.push('|' + headers.map(() => '------').join('|') + '|'); | |
| for (const entry of stack) { | |
| const cumulative = displaySize(entry.cumulativeValue, metric.unit); | |
| const cumulativePercent = displayPercentage( | |
| entry.cumulativeValue, | |
| unfilteredCumulativeValue, | |
| ); | |
| const self = displaySize(entry.selfValue, metric.unit); | |
| const selfPercent = displayPercentage( | |
| entry.selfValue, | |
| unfilteredCumulativeValue, | |
| ); | |
| const cols = [entry.name]; | |
| for (const key of unaggKeys) { | |
| cols.push(entry.properties.get(key)?.value ?? ''); | |
| } | |
| cols.push( | |
| `${cumulative} (${cumulativePercent})`, | |
| `${self} (${selfPercent})`, | |
| ); | |
| for (const key of aggKeys) { | |
| cols.push(entry.properties.get(key)?.value ?? ''); | |
| } | |
| lines.push('| ' + cols.join(' | ') + ' |'); | |
| } | |
| return lines.join('\n'); | |
| } | |
| private createReducedProperties( | |
| properties: ReadonlyMap<string, TreeExplorerPropertyDefinition>, | |
| ): ReadonlyMap<string, string> { | |
| return new Map([...properties].map(([key, {value}]) => [key, value])); | |
| } | |
| } | |
| // Builds a table of all currently displayed nodes (i.e. with filters | |
| // applied) in the given format. Metric values are raw numbers in the | |
| // metric's unit so they can be aggregated in spreadsheets; the tree | |
| // structure is preserved via the id/parentId columns. | |
| export function buildFlamegraphExportString( | |
| data: TreeExplorerData, | |
| metric: TreeExplorerMetric, | |
| format: ExportFormat, | |
| ): string { | |
| const {nodes} = data; | |
| const unitDisplay = getUnitDisplayName(metric.unit); | |
| const unaggKeys: string[] = []; | |
| const aggKeys: string[] = []; | |
| const propDisplayNames = new Map<string, string>(); | |
| for (const node of nodes) { | |
| for (const [key, prop] of node.properties) { | |
| const keys = prop.isAggregatable ? aggKeys : unaggKeys; | |
| if (!keys.includes(key)) { | |
| keys.push(key); | |
| propDisplayNames.set(key, prop.displayName); | |
| } | |
| } | |
| } | |
| const columns = [ | |
| 'id', | |
| 'parentId', | |
| 'depth', | |
| 'name', | |
| ...unaggKeys, | |
| 'cumulativeValue', | |
| 'selfValue', | |
| ...aggKeys, | |
| ]; | |
| const columnNames: Record<string, string> = { | |
| ...Object.fromEntries(propDisplayNames), | |
| id: 'Id', | |
| parentId: 'Parent Id', | |
| depth: 'Depth', | |
| name: metric.nameColumnLabel ?? 'Name', | |
| cumulativeValue: `Cumulative ${metric.name} (${unitDisplay})`, | |
| selfValue: `Self ${metric.name} (${unitDisplay})`, | |
| }; | |
| const rows = nodes.map((n) => { | |
| const row: Record<string, string> = { | |
| id: n.id.toString(), | |
| parentId: n.parentId.toString(), | |
| depth: n.depth.toString(), | |
| name: n.name, | |
| cumulativeValue: n.cumulativeValue.toString(), | |
| selfValue: n.selfValue.toString(), | |
| }; | |
| for (const key of [...unaggKeys, ...aggKeys]) { | |
| row[key] = n.properties.get(key)?.value ?? ''; | |
| } | |
| return row; | |
| }); | |
| switch (format) { | |
| case 'tsv': | |
| return formatAsTSV(columns, columnNames, rows); | |
| case 'json': | |
| return formatAsJSON(columns, columnNames, rows); | |
| case 'markdown': | |
| return formatAsMarkdown(columns, columnNames, rows); | |
| default: | |
| assertUnreachable(format); | |
| } | |
| } | |
| function computeRenderNodes( | |
| {nodes, allRootsCumulativeValue, minDepth}: TreeExplorerData, | |
| zoomRegion: ZoomRegion, | |
| canvasWidth: number, | |
| ): ReadonlyArray<RenderNode> { | |
| const renderNodes: RenderNode[] = []; | |
| const mergedKeyToX = new Map<string, number>(); | |
| const keyToChildMergedIdx = new Map<string, number>(); | |
| renderNodes.push({ | |
| x: 0, | |
| y: -minDepth * NODE_HEIGHT, | |
| width: canvasWidth, | |
| source: { | |
| kind: 'ROOT', | |
| queryXStart: 0, | |
| queryXEnd: allRootsCumulativeValue, | |
| type: 'ROOT', | |
| }, | |
| state: | |
| zoomRegion.queryXStart === 0 && | |
| zoomRegion.queryXEnd === allRootsCumulativeValue | |
| ? 'NORMAL' | |
| : 'PARTIAL', | |
| }); | |
| const zoomQueryWidth = zoomRegion.queryXEnd - zoomRegion.queryXStart; | |
| for (let i = 0; i < nodes.length; i++) { | |
| const {id, parentId, depth, xStart: qXStart, xEnd: qXEnd} = nodes[i]; | |
| assertTrue(depth !== 0); | |
| const depthMatchingZoom = isDepthMatchingZoom(depth, zoomRegion); | |
| if ( | |
| depthMatchingZoom && | |
| (qXEnd <= zoomRegion.queryXStart || qXStart >= zoomRegion.queryXEnd) | |
| ) { | |
| continue; | |
| } | |
| const queryXPerPx = depthMatchingZoom | |
| ? zoomQueryWidth / canvasWidth | |
| : allRootsCumulativeValue / canvasWidth; | |
| const relativeXStart = depthMatchingZoom | |
| ? qXStart - zoomRegion.queryXStart | |
| : qXStart; | |
| const relativeXEnd = depthMatchingZoom | |
| ? qXEnd - zoomRegion.queryXStart | |
| : qXEnd; | |
| const relativeWidth = relativeXEnd - relativeXStart; | |
| const x = Math.max(0, relativeXStart) / queryXPerPx; | |
| const y = NODE_HEIGHT * (depth - minDepth); | |
| const width = depthMatchingZoom | |
| ? Math.min(relativeWidth, zoomQueryWidth) / queryXPerPx | |
| : relativeWidth / queryXPerPx; | |
| const state = computeState(qXStart, qXEnd, zoomRegion, depthMatchingZoom); | |
| if (width < MIN_PIXEL_DISPLAYED) { | |
| // Check if parent was merged - if so, use x-position-based key so that | |
| // children of different parents that were merged together also merge. | |
| // This enables recursive merging: if parents A and B merged into the | |
| // same visual node, their children should also merge together. | |
| const parentMergedX = mergedKeyToX.get(`${parentId}_${depth}`); | |
| const parentChildMergeKey = | |
| parentMergedX !== undefined | |
| ? `x_${Math.round(parentMergedX)}_${depth}` | |
| : `p_${parentId}_${depth}`; | |
| const mergedXKey = `${id}_${depth > 0 ? depth + 1 : depth - 1}`; | |
| const childMergedIdx = keyToChildMergedIdx.get(parentChildMergeKey); | |
| if (childMergedIdx !== undefined) { | |
| const r = renderNodes[childMergedIdx]; | |
| const mergedWidth = isDepthMatchingZoom(depth, zoomRegion) | |
| ? Math.min(qXEnd - r.source.queryXStart, zoomQueryWidth) / queryXPerPx | |
| : (qXEnd - r.source.queryXStart) / queryXPerPx; | |
| renderNodes[childMergedIdx] = { | |
| ...r, | |
| width: Math.max(mergedWidth, MIN_PIXEL_DISPLAYED), | |
| source: { | |
| ...(r.source as MergedSource), | |
| queryXEnd: qXEnd, | |
| }, | |
| }; | |
| mergedKeyToX.set(mergedXKey, r.x); | |
| continue; | |
| } | |
| const mergedX = parentMergedX ?? x; | |
| renderNodes.push({ | |
| x: mergedX, | |
| y, | |
| width: Math.max(width, MIN_PIXEL_DISPLAYED), | |
| source: { | |
| kind: 'MERGED', | |
| queryXStart: qXStart, | |
| queryXEnd: qXEnd, | |
| type: depth > 0 ? 'BELOW_ROOT' : 'ABOVE_ROOT', | |
| }, | |
| state, | |
| }); | |
| keyToChildMergedIdx.set(parentChildMergeKey, renderNodes.length - 1); | |
| mergedKeyToX.set(mergedXKey, mergedX); | |
| continue; | |
| } | |
| renderNodes.push({ | |
| x, | |
| y, | |
| width, | |
| source: { | |
| kind: 'NODE', | |
| queryXStart: qXStart, | |
| queryXEnd: qXEnd, | |
| queryIdx: i, | |
| type: depth > 0 ? 'BELOW_ROOT' : 'ABOVE_ROOT', | |
| }, | |
| state, | |
| }); | |
| } | |
| return renderNodes; | |
| } | |
| function isDepthMatchingZoom(depth: number, zoomRegion: ZoomRegion): boolean { | |
| assertTrue( | |
| depth !== 0, | |
| 'Handling zooming root not possible in this function', | |
| ); | |
| return ( | |
| (depth > 0 && zoomRegion.type === 'BELOW_ROOT') || | |
| (depth < 0 && zoomRegion.type === 'ABOVE_ROOT') | |
| ); | |
| } | |
| function computeState( | |
| qXStart: number, | |
| qXEnd: number, | |
| zoomRegion: ZoomRegion, | |
| isDepthMatchingZoom: boolean, | |
| ) { | |
| if (!isDepthMatchingZoom) { | |
| return 'NORMAL'; | |
| } | |
| if (qXStart === zoomRegion.queryXStart && qXEnd === zoomRegion.queryXEnd) { | |
| return 'SELECTED'; | |
| } | |
| if (qXStart < zoomRegion.queryXStart || qXEnd > zoomRegion.queryXEnd) { | |
| return 'PARTIAL'; | |
| } | |
| return 'NORMAL'; | |
| } | |
| function isIntersecting( | |
| needleX: number | undefined, | |
| needleY: number | undefined, | |
| {x, y, width}: RenderNode, | |
| ) { | |
| if (needleX === undefined || needleY === undefined) { | |
| return false; | |
| } | |
| return ( | |
| needleX >= x && | |
| needleX < x + width && | |
| needleY >= y && | |
| needleY < y + NODE_HEIGHT | |
| ); | |
| } | |
| // Unfortunately, widgets *cannot* depend on components so we cannot use the | |
| // colorizer code. Since we need very little of that code anyway, just inline | |
| // what we need here. | |
| const PERCEIVED_BRIGHTNESS_LIMIT = 180; | |
| const WHITE_COLOR = new HSLColor([0, 0, 100]); | |
| const BLACK_COLOR = new HSLColor([0, 0, 0]); | |
| // Lightness 85 ensures even darken(10) stays above brightness threshold for black text | |
| const GRAY_VARIANT_COLOR = new HSLColor([0, 0, 85]); | |
| interface ColorScheme { | |
| readonly base: Color; | |
| readonly variant: Color; | |
| readonly textBase: Color; | |
| readonly textVariant: Color; | |
| } | |
| function makeColorScheme(base: Color, variant: Color): ColorScheme { | |
| // Use the same text color for both base and variant to prevent text color | |
| // switching on hover. The text color is determined by the base color only. | |
| const textColor = | |
| base.perceivedBrightness >= PERCEIVED_BRIGHTNESS_LIMIT | |
| ? BLACK_COLOR | |
| : WHITE_COLOR; | |
| return { | |
| base, | |
| variant, | |
| textBase: textColor, | |
| textVariant: textColor, | |
| }; | |
| } | |
| // Pre-computed color schemes for special cases | |
| const GREYED_COLOR_SCHEME = makeColorScheme( | |
| GRAY_VARIANT_COLOR, | |
| GRAY_VARIANT_COLOR.darken(5), | |
| ); | |
| const ROOT_COLOR_SCHEME = makeColorScheme( | |
| GRAY_VARIANT_COLOR.darken(10), | |
| GRAY_VARIANT_COLOR.darken(15), | |
| ); | |
| // Cache for computed color schemes by name | |
| const colorSchemeCache = new Map<string, ColorScheme>(); | |
| function getFlamegraphColorScheme(name: string, greyed: boolean): ColorScheme { | |
| if (greyed) { | |
| return GREYED_COLOR_SCHEME; | |
| } | |
| if (name === 'unknown' || name === 'root') { | |
| return ROOT_COLOR_SCHEME; | |
| } | |
| // Check cache first | |
| let scheme = colorSchemeCache.get(name); | |
| if (scheme !== undefined) { | |
| return scheme; | |
| } | |
| // Hash the name to get a predictable hue, then create color with fixed | |
| // saturation and lightness values to match what pprof web UI does. | |
| const hue = hash(name, 360); | |
| const base = new HSLColor({h: hue, s: 46, l: 80}); | |
| scheme = makeColorScheme(base, base.darken(15).saturate(15)); | |
| colorSchemeCache.set(name, scheme); | |
| return scheme; | |
| } |