Flamegraph: Rendering from tree instead of levels (#76215)

This commit is contained in:
Andrej Ocenas 2023-10-18 15:53:07 +02:00 committed by GitHub
parent 43add83d1a
commit c99b978857
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 53338 additions and 33921 deletions

View File

@ -16,20 +16,17 @@
// OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
// TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
// THIS SOFTWARE.
import { css } from '@emotion/css';
import React, { MouseEvent as ReactMouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMeasure } from 'react-use';
import { css, cx } from '@emotion/css';
import React, { useMemo } from 'react';
import { Icon } from '@grafana/ui';
import { PIXELS_PER_LEVEL } from '../constants';
import { ClickedItemData, ColorScheme, ColorSchemeDiff, TextAlign } from '../types';
import FlameGraphContextMenu from './FlameGraphContextMenu';
import FlameGraphCanvas from './FlameGraphCanvas';
import FlameGraphMetadata from './FlameGraphMetadata';
import FlameGraphTooltip from './FlameGraphTooltip';
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
import { getBarX, useFlameRender } from './rendering';
import { FlameGraphDataContainer } from './dataTransform';
type Props = {
data: FlameGraphDataContainer;
@ -67,118 +64,66 @@ const FlameGraph = ({
}: Props) => {
const styles = getStyles();
const [levels, totalProfileTicks, totalProfileTicksRight, totalViewTicks, callersCount] = useMemo(() => {
const [levels, levelsCallers, totalProfileTicks, totalProfileTicksRight, totalViewTicks] = useMemo(() => {
let levels = data.getLevels();
let totalProfileTicks = levels.length ? levels[0][0].value : 0;
let totalProfileTicksRight = levels.length ? levels[0][0].valueRight : undefined;
let callersCount = 0;
let totalViewTicks = totalProfileTicks;
let levelsCallers = undefined;
if (sandwichItem) {
const [callers, callees] = data.getSandwichLevels(sandwichItem);
levels = [...callers, [], ...callees];
// We need this separate as in case of diff profile we to compute diff colors based on the original ticks.
levels = callees;
levelsCallers = callers;
// We need this separate as in case of diff profile we want to compute diff colors based on the original ticks.
totalViewTicks = callees[0]?.[0]?.value ?? 0;
callersCount = callers.length;
}
return [levels, totalProfileTicks, totalProfileTicksRight, totalViewTicks, callersCount];
return [levels, levelsCallers, totalProfileTicks, totalProfileTicksRight, totalViewTicks];
}, [data, sandwichItem]);
const [sizeRef, { width: wrapperWidth }] = useMeasure<HTMLDivElement>();
const graphRef = useRef<HTMLCanvasElement>(null);
const [tooltipItem, setTooltipItem] = useState<LevelItem>();
const [clickedItemData, setClickedItemData] = useState<ClickedItemData>();
useFlameRender({
canvasRef: graphRef,
colorScheme,
const commonCanvasProps = {
data,
focusedItemData,
levels,
rangeMax,
rangeMin,
rangeMax,
search,
setRangeMin,
setRangeMax,
onItemFocused,
focusedItemData,
textAlign,
onSandwich,
colorScheme,
totalProfileTicks,
totalProfileTicksRight,
totalViewTicks,
// We need this so that if we have a diff profile and are in sandwich view we still show the same diff colors.
totalColorTicks: data.isDiffFlamegraph() ? totalProfileTicks : totalViewTicks,
totalTicksRight: totalProfileTicksRight,
wrapperWidth,
});
};
const canvas = levelsCallers ? (
<>
<div className={styles.sandwichCanvasWrapper}>
<div className={styles.sandwichMarker}>
Callers
<Icon className={styles.sandwichMarkerIcon} name={'arrow-down'} />
</div>
<FlameGraphCanvas
{...commonCanvasProps}
root={levelsCallers[levelsCallers.length - 1][0]}
depth={levelsCallers.length}
direction={'parents'}
/>
</div>
const onGraphClick = useCallback(
(e: ReactMouseEvent<HTMLCanvasElement>) => {
setTooltipItem(undefined);
const pixelsPerTick = graphRef.current!.clientWidth / totalViewTicks / (rangeMax - rangeMin);
const { levelIndex, barIndex } = convertPixelCoordinatesToBarCoordinates(
{ x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY },
levels,
pixelsPerTick,
totalViewTicks,
rangeMin
);
// if clicking on a block in the canvas
if (barIndex !== -1 && !isNaN(levelIndex) && !isNaN(barIndex)) {
const item = levels[levelIndex][barIndex];
setClickedItemData({
posY: e.clientY,
posX: e.clientX,
item,
level: levelIndex,
label: data.getLabel(item.itemIndexes[0]),
});
} else {
// if clicking on the canvas but there is no block beneath the cursor
setClickedItemData(undefined);
}
},
[data, rangeMin, rangeMax, totalViewTicks, levels]
<div className={styles.sandwichCanvasWrapper}>
<div className={cx(styles.sandwichMarker, styles.sandwichMarkerCalees)}>
<Icon className={styles.sandwichMarkerIcon} name={'arrow-up'} />
Callees
</div>
<FlameGraphCanvas {...commonCanvasProps} root={levels[0][0]} depth={levels.length} direction={'children'} />
</div>
</>
) : (
<FlameGraphCanvas {...commonCanvasProps} root={levels[0][0]} depth={levels.length} direction={'children'} />
);
const [mousePosition, setMousePosition] = useState<{ x: number; y: number }>();
const onGraphMouseMove = useCallback(
(e: ReactMouseEvent<HTMLCanvasElement>) => {
if (clickedItemData === undefined) {
setTooltipItem(undefined);
setMousePosition(undefined);
const pixelsPerTick = graphRef.current!.clientWidth / totalViewTicks / (rangeMax - rangeMin);
const { levelIndex, barIndex } = convertPixelCoordinatesToBarCoordinates(
{ x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY },
levels,
pixelsPerTick,
totalViewTicks,
rangeMin
);
if (barIndex !== -1 && !isNaN(levelIndex) && !isNaN(barIndex)) {
setMousePosition({ x: e.clientX, y: e.clientY });
setTooltipItem(levels[levelIndex][barIndex]);
}
}
},
[rangeMin, rangeMax, totalViewTicks, clickedItemData, levels, setMousePosition]
);
const onGraphMouseLeave = useCallback(() => {
setTooltipItem(undefined);
}, []);
// hide context menu if outside the flame graph canvas is clicked
useEffect(() => {
const handleOnClick = (e: MouseEvent) => {
if (
e.target instanceof HTMLElement &&
e.target.parentElement?.id !== 'flameGraphCanvasContainer_clickOutsideCheck'
) {
setClickedItemData(undefined);
}
};
window.addEventListener('click', handleOnClick);
return () => window.removeEventListener('click', handleOnClick);
}, [setClickedItemData]);
return (
<div className={styles.graph}>
<FlameGraphMetadata
@ -189,49 +134,7 @@ const FlameGraph = ({
onFocusPillClick={onFocusPillClick}
onSandwichPillClick={onSandwichPillClick}
/>
<div className={styles.canvasContainer}>
{sandwichItem && (
<div>
<div
className={styles.sandwichMarker}
style={{ height: (callersCount * PIXELS_PER_LEVEL) / window.devicePixelRatio }}
>
Callers
<Icon className={styles.sandwichMarkerIcon} name={'arrow-down'} />
</div>
<div className={styles.sandwichMarker} style={{ marginTop: PIXELS_PER_LEVEL / window.devicePixelRatio }}>
<Icon className={styles.sandwichMarkerIcon} name={'arrow-up'} />
Callees
</div>
</div>
)}
<div className={styles.canvasWrapper} id="flameGraphCanvasContainer_clickOutsideCheck" ref={sizeRef}>
<canvas
ref={graphRef}
data-testid="flameGraph"
onClick={onGraphClick}
onMouseMove={onGraphMouseMove}
onMouseLeave={onGraphMouseLeave}
/>
</div>
</div>
<FlameGraphTooltip position={mousePosition} item={tooltipItem} data={data} totalTicks={totalViewTicks} />
{clickedItemData && (
<FlameGraphContextMenu
itemData={clickedItemData}
onMenuItemClick={() => {
setClickedItemData(undefined);
}}
onItemFocus={() => {
setRangeMin(clickedItemData.item.start / totalViewTicks);
setRangeMax((clickedItemData.item.start + clickedItemData.item.value) / totalViewTicks);
onItemFocused(clickedItemData);
}}
onSandwich={() => {
onSandwich(data.getLabel(clickedItemData.item.itemIndexes[0]));
}}
/>
)}
{canvas}
</div>
);
};
@ -244,15 +147,10 @@ const getStyles = () => ({
flex-grow: 1;
flex-basis: 50%;
`,
canvasContainer: css`
label: canvasContainer;
sandwichCanvasWrapper: css`
label: sandwichCanvasWrapper;
display: flex;
`,
canvasWrapper: css`
label: canvasWrapper;
cursor: pointer;
flex: 1;
overflow: hidden;
margin-bottom: ${PIXELS_PER_LEVEL / window.devicePixelRatio}px;
`,
sandwichMarker: css`
label: sandwichMarker;
@ -261,58 +159,15 @@ const getStyles = () => ({
overflow: hidden;
white-space: nowrap;
`,
sandwichMarkerCalees: css`
label: sandwichMarkerCalees;
text-align: right;
`,
sandwichMarkerIcon: css`
label: sandwichMarkerIcon;
vertical-align: baseline;
`,
});
// Convert pixel coordinates to bar coordinates in the levels array so that we can add mouse events like clicks to
// the canvas.
const convertPixelCoordinatesToBarCoordinates = (
// position relative to the start of the graph
pos: { x: number; y: number },
levels: LevelItem[][],
pixelsPerTick: number,
totalTicks: number,
rangeMin: number
) => {
const levelIndex = Math.floor(pos.y / (PIXELS_PER_LEVEL / window.devicePixelRatio));
const barIndex = getBarIndex(pos.x, levels[levelIndex], pixelsPerTick, totalTicks, rangeMin);
return { levelIndex, barIndex };
};
/**
* Binary search for a bar in a level, based on the X pixel coordinate. Useful for detecting which bar did user click
* on.
*/
const getBarIndex = (x: number, level: LevelItem[], pixelsPerTick: number, totalTicks: number, rangeMin: number) => {
if (level) {
let start = 0;
let end = level.length - 1;
while (start <= end) {
const midIndex = (start + end) >> 1;
const startOfBar = getBarX(level[midIndex].start, totalTicks, rangeMin, pixelsPerTick);
const startOfNextBar = getBarX(
level[midIndex].start + level[midIndex].value,
totalTicks,
rangeMin,
pixelsPerTick
);
if (startOfBar <= x && startOfNextBar >= x) {
return midIndex;
}
if (startOfBar > x) {
end = midIndex - 1;
} else {
start = midIndex + 1;
}
}
}
return -1;
};
export default FlameGraph;

View File

@ -0,0 +1,259 @@
import { css } from '@emotion/css';
import React, { MouseEvent as ReactMouseEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useMeasure } from 'react-use';
import { PIXELS_PER_LEVEL } from '../constants';
import { ClickedItemData, ColorScheme, ColorSchemeDiff, TextAlign } from '../types';
import FlameGraphContextMenu from './FlameGraphContextMenu';
import FlameGraphTooltip from './FlameGraphTooltip';
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
import { getBarX, useFlameRender } from './rendering';
type Props = {
data: FlameGraphDataContainer;
rangeMin: number;
rangeMax: number;
search: string;
setRangeMin: (range: number) => void;
setRangeMax: (range: number) => void;
style?: React.CSSProperties;
onItemFocused: (data: ClickedItemData) => void;
focusedItemData?: ClickedItemData;
textAlign: TextAlign;
onSandwich: (label: string) => void;
colorScheme: ColorScheme | ColorSchemeDiff;
root: LevelItem;
direction: 'children' | 'parents';
// Depth in number of levels
depth: number;
totalProfileTicks: number;
totalProfileTicksRight?: number;
totalViewTicks: number;
};
const FlameGraphCanvas = ({
data,
rangeMin,
rangeMax,
search,
setRangeMin,
setRangeMax,
onItemFocused,
focusedItemData,
textAlign,
onSandwich,
colorScheme,
totalProfileTicks,
totalProfileTicksRight,
totalViewTicks,
root,
direction,
depth,
}: Props) => {
const styles = getStyles();
const [sizeRef, { width: wrapperWidth }] = useMeasure<HTMLDivElement>();
const graphRef = useRef<HTMLCanvasElement>(null);
const [tooltipItem, setTooltipItem] = useState<LevelItem>();
const [clickedItemData, setClickedItemData] = useState<ClickedItemData>();
useFlameRender({
canvasRef: graphRef,
colorScheme,
data,
focusedItemData,
root,
direction,
depth,
rangeMax,
rangeMin,
search,
textAlign,
totalViewTicks,
// We need this so that if we have a diff profile and are in sandwich view we still show the same diff colors.
totalColorTicks: data.isDiffFlamegraph() ? totalProfileTicks : totalViewTicks,
totalTicksRight: totalProfileTicksRight,
wrapperWidth,
});
const onGraphClick = useCallback(
(e: ReactMouseEvent<HTMLCanvasElement>) => {
setTooltipItem(undefined);
const pixelsPerTick = graphRef.current!.clientWidth / totalViewTicks / (rangeMax - rangeMin);
const item = convertPixelCoordinatesToBarCoordinates(
{ x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY },
root,
direction,
depth,
pixelsPerTick,
totalViewTicks,
rangeMin
);
// if clicking on a block in the canvas
if (item) {
setClickedItemData({
posY: e.clientY,
posX: e.clientX,
item,
label: data.getLabel(item.itemIndexes[0]),
});
} else {
// if clicking on the canvas but there is no block beneath the cursor
setClickedItemData(undefined);
}
},
[data, rangeMin, rangeMax, totalViewTicks, root, direction, depth]
);
const [mousePosition, setMousePosition] = useState<{ x: number; y: number }>();
const onGraphMouseMove = useCallback(
(e: ReactMouseEvent<HTMLCanvasElement>) => {
if (clickedItemData === undefined) {
setTooltipItem(undefined);
setMousePosition(undefined);
const pixelsPerTick = graphRef.current!.clientWidth / totalViewTicks / (rangeMax - rangeMin);
const item = convertPixelCoordinatesToBarCoordinates(
{ x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY },
root,
direction,
depth,
pixelsPerTick,
totalViewTicks,
rangeMin
);
if (item) {
setMousePosition({ x: e.clientX, y: e.clientY });
setTooltipItem(item);
}
}
},
[rangeMin, rangeMax, totalViewTicks, clickedItemData, setMousePosition, root, direction, depth]
);
const onGraphMouseLeave = useCallback(() => {
setTooltipItem(undefined);
}, []);
// hide context menu if outside the flame graph canvas is clicked
useEffect(() => {
const handleOnClick = (e: MouseEvent) => {
if (
e.target instanceof HTMLElement &&
e.target.parentElement?.id !== 'flameGraphCanvasContainer_clickOutsideCheck'
) {
setClickedItemData(undefined);
}
};
window.addEventListener('click', handleOnClick);
return () => window.removeEventListener('click', handleOnClick);
}, [setClickedItemData]);
return (
<div className={styles.graph}>
<div className={styles.canvasWrapper} id="flameGraphCanvasContainer_clickOutsideCheck" ref={sizeRef}>
<canvas
ref={graphRef}
data-testid="flameGraph"
onClick={onGraphClick}
onMouseMove={onGraphMouseMove}
onMouseLeave={onGraphMouseLeave}
/>
</div>
<FlameGraphTooltip position={mousePosition} item={tooltipItem} data={data} totalTicks={totalViewTicks} />
{clickedItemData && (
<FlameGraphContextMenu
itemData={clickedItemData}
onMenuItemClick={() => {
setClickedItemData(undefined);
}}
onItemFocus={() => {
setRangeMin(clickedItemData.item.start / totalViewTicks);
setRangeMax((clickedItemData.item.start + clickedItemData.item.value) / totalViewTicks);
onItemFocused(clickedItemData);
}}
onSandwich={() => {
onSandwich(data.getLabel(clickedItemData.item.itemIndexes[0]));
}}
/>
)}
</div>
);
};
const getStyles = () => ({
graph: css({
label: 'graph',
overflow: 'auto',
height: '100%',
flexGrow: 1,
flexBasis: '50%',
}),
canvasContainer: css({
label: 'canvasContainer',
display: 'flex',
}),
canvasWrapper: css({
label: 'canvasWrapper',
cursor: 'pointer',
flex: 1,
overflow: 'hidden',
}),
sandwichMarker: css({
label: 'sandwichMarker',
writingMode: 'vertical-lr',
transform: 'rotate(180deg)',
overflow: 'hidden',
whiteSpace: 'nowrap',
}),
sandwichMarkerIcon: css({
label: 'sandwichMarkerIcon',
verticalAlign: 'baseline',
}),
});
const convertPixelCoordinatesToBarCoordinates = (
// position relative to the start of the graph
pos: { x: number; y: number },
root: LevelItem,
direction: 'children' | 'parents',
depth: number,
pixelsPerTick: number,
totalTicks: number,
rangeMin: number
): LevelItem | undefined => {
let next: LevelItem | undefined = root;
let currentLevel = direction === 'children' ? 0 : depth - 1;
const levelIndex = Math.floor(pos.y / (PIXELS_PER_LEVEL / window.devicePixelRatio));
let found = undefined;
while (next) {
const node: LevelItem = next;
next = undefined;
if (currentLevel === levelIndex) {
found = node;
break;
}
const nextList = direction === 'children' ? node.children : node.parents || [];
for (const child of nextList) {
const xStart = getBarX(child.start, totalTicks, rangeMin, pixelsPerTick);
const xEnd = getBarX(child.start + child.value, totalTicks, rangeMin, pixelsPerTick);
if (xStart <= pos.x && pos.x < xEnd) {
next = child;
currentLevel = currentLevel + (direction === 'children' ? 1 : -1);
break;
}
}
}
return found;
};
export default FlameGraphCanvas;

View File

@ -45,8 +45,8 @@ describe('FlameGraphMetadata', () => {
children: [],
itemIndexes: [3],
start: 3,
level: 0,
},
level: 0,
posX: 0,
posY: 0,
},

View File

@ -33,7 +33,7 @@ describe('FlameGraphTooltip', () => {
it('for bytes', () => {
const tooltipData = getTooltipData(
setupData('bytes'),
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [] },
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [], level: 0 },
8_624_078_250
);
expect(tooltipData).toEqual({
@ -49,7 +49,7 @@ describe('FlameGraphTooltip', () => {
it('with default unit', () => {
const tooltipData = getTooltipData(
setupData('none'),
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [] },
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [], level: 0 },
8_624_078_250
);
expect(tooltipData).toEqual({
@ -65,7 +65,7 @@ describe('FlameGraphTooltip', () => {
it('without unit', () => {
const tooltipData = getTooltipData(
setupData('none'),
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [] },
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [], level: 0 },
8_624_078_250
);
expect(tooltipData).toEqual({
@ -81,7 +81,7 @@ describe('FlameGraphTooltip', () => {
it('for objects', () => {
const tooltipData = getTooltipData(
setupData('short'),
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [] },
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [], level: 0 },
8_624_078_250
);
expect(tooltipData).toEqual({
@ -97,7 +97,7 @@ describe('FlameGraphTooltip', () => {
it('for nanoseconds', () => {
const tooltipData = getTooltipData(
setupData('ns'),
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [] },
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [], level: 0 },
8_624_078_250
);
expect(tooltipData).toEqual({
@ -115,7 +115,7 @@ describe('getDiffTooltipData', () => {
it('works with diff data', () => {
const tooltipData = getDiffTooltipData(
setupDiffData(),
{ start: 0, itemIndexes: [1], value: 90, valueRight: 40, children: [] },
{ start: 0, itemIndexes: [1], value: 90, valueRight: 40, children: [], level: 0 },
200
);
expect(tooltipData).toEqual([

View File

@ -19,15 +19,15 @@ describe('nestedSetToLevels', () => {
});
const [levels] = nestedSetToLevels(new FlameGraphDataContainer(frame));
const n9: LevelItem = { itemIndexes: [8], start: 5, children: [], value: 1 };
const n8: LevelItem = { itemIndexes: [7], start: 5, children: [n9], value: 2 };
const n7: LevelItem = { itemIndexes: [6], start: 5, children: [n8], value: 3 };
const n6: LevelItem = { itemIndexes: [5], start: 5, children: [n7], value: 4 };
const n5: LevelItem = { itemIndexes: [4], start: 3, children: [], value: 1 };
const n4: LevelItem = { itemIndexes: [3], start: 0, children: [], value: 1 };
const n3: LevelItem = { itemIndexes: [2], start: 0, children: [n4], value: 3 };
const n2: LevelItem = { itemIndexes: [1], start: 0, children: [n3, n5], value: 5 };
const n1: LevelItem = { itemIndexes: [0], start: 0, children: [n2, n6], value: 10 };
const n9: LevelItem = { itemIndexes: [8], start: 5, children: [], value: 1, level: 4 };
const n8: LevelItem = { itemIndexes: [7], start: 5, children: [n9], value: 2, level: 3 };
const n7: LevelItem = { itemIndexes: [6], start: 5, children: [n8], value: 3, level: 2 };
const n6: LevelItem = { itemIndexes: [5], start: 5, children: [n7], value: 4, level: 1 };
const n5: LevelItem = { itemIndexes: [4], start: 3, children: [], value: 1, level: 2 };
const n4: LevelItem = { itemIndexes: [3], start: 0, children: [], value: 1, level: 3 };
const n3: LevelItem = { itemIndexes: [2], start: 0, children: [n4], value: 3, level: 2 };
const n2: LevelItem = { itemIndexes: [1], start: 0, children: [n3, n5], value: 5, level: 1 };
const n1: LevelItem = { itemIndexes: [0], start: 0, children: [n2, n6], value: 10, level: 0 };
n2.parents = [n1];
n6.parents = [n1];
@ -56,10 +56,10 @@ describe('nestedSetToLevels', () => {
});
const [levels] = nestedSetToLevels(new FlameGraphDataContainer(frame));
const n4: LevelItem = { itemIndexes: [3], start: 8, children: [], value: 1 };
const n3: LevelItem = { itemIndexes: [2], start: 5, children: [], value: 3 };
const n2: LevelItem = { itemIndexes: [1], start: 0, children: [], value: 5 };
const n1: LevelItem = { itemIndexes: [0], start: 0, children: [n2, n3, n4], value: 10 };
const n4: LevelItem = { itemIndexes: [3], start: 8, children: [], value: 1, level: 1 };
const n3: LevelItem = { itemIndexes: [2], start: 5, children: [], value: 3, level: 1 };
const n2: LevelItem = { itemIndexes: [1], start: 0, children: [], value: 5, level: 1 };
const n1: LevelItem = { itemIndexes: [0], start: 0, children: [n2, n3, n4], value: 10, level: 0 };
n2.parents = [n1];
n3.parents = [n1];

View File

@ -25,9 +25,17 @@ export type LevelItem = {
// node.
itemIndexes: number[];
children: LevelItem[];
level: number;
parents?: LevelItem[];
};
export type CollapseConfig = {
items: LevelItem[];
collapsed: boolean;
};
export type CollapsedMap = Map<LevelItem, CollapseConfig>;
/**
* Convert data frame with nested set format into array of level. This is mainly done for compatibility with current
* rendering code.
@ -65,6 +73,7 @@ export function nestedSetToLevels(container: FlameGraphDataContainer): [LevelIte
start: offset,
parents: parent && [parent],
children: [],
level: currentLevel,
};
if (uniqueLabels[container.getLabel(i)]) {

View File

@ -1,7 +1,7 @@
import { createDataFrame, FieldType } from '@grafana/data';
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
import { getRectDimensionsForLevel } from './rendering';
import { walkTree } from './rendering';
function makeDataFrame(fields: Record<string, Array<number | string>>) {
return createDataFrame({
@ -13,76 +13,99 @@ function makeDataFrame(fields: Record<string, Array<number | string>>) {
});
}
describe('getRectDimensionsForLevel', () => {
it('should render a single item', () => {
const level: LevelItem[] = [{ start: 0, itemIndexes: [0], children: [], value: 100 }];
type RenderData = {
item: LevelItem;
x: number;
y: number;
width: number;
height: number;
label: string;
collapsed: boolean;
};
describe('walkTree', () => {
it('correctly compute sizes for a single item', () => {
const root: LevelItem = { start: 0, itemIndexes: [0], children: [], value: 100, level: 0 };
const container = new FlameGraphDataContainer(makeDataFrame({ value: [100], level: [1], label: ['1'], self: [0] }));
const result = getRectDimensionsForLevel(container, level, 1, 100, 0, 10);
expect(result).toEqual([
{
width: 999,
height: 22,
itemIndex: 0,
x: 0,
y: 22,
collapsed: false,
ticks: 100,
label: '1',
unitLabel: '100',
},
]);
walkTree(root, 'children', container, 100, 0, 1, 100, (item, x, y, width, height, label, collapsed) => {
expect(item).toEqual(root);
expect(x).toEqual(0);
expect(y).toEqual(0);
expect(width).toEqual(99); // -1 for border
expect(height).toEqual(22);
expect(label).toEqual('1');
expect(collapsed).toEqual(false);
});
});
it('should render a multiple items', () => {
const level: LevelItem[] = [
{ start: 0, itemIndexes: [0], children: [], value: 100 },
{ start: 100, itemIndexes: [1], children: [], value: 50 },
{ start: 150, itemIndexes: [2], children: [], value: 50 },
];
const root: LevelItem = {
start: 0,
itemIndexes: [0],
value: 100,
level: 0,
children: [
{ start: 0, itemIndexes: [1], children: [], value: 50, level: 1 },
{ start: 50, itemIndexes: [2], children: [], value: 50, level: 1 },
],
};
const container = new FlameGraphDataContainer(
makeDataFrame({ value: [100, 50, 50], level: [2, 2, 2], label: ['1', '2', '3'], self: [0, 0, 0] })
makeDataFrame({ value: [100, 50, 50], level: [0, 1, 1], label: ['1', '2', '3'], self: [0, 50, 50] })
);
const result = getRectDimensionsForLevel(container, level, 2, 100, 0, 10);
expect(result).toEqual([
{ width: 999, height: 22, x: 0, y: 44, collapsed: false, ticks: 100, label: '1', unitLabel: '100', itemIndex: 0 },
{
width: 499,
height: 22,
x: 1000,
y: 44,
collapsed: false,
ticks: 50,
label: '2',
unitLabel: '50',
itemIndex: 1,
},
{
width: 499,
height: 22,
x: 1500,
y: 44,
collapsed: false,
ticks: 50,
label: '3',
unitLabel: '50',
itemIndex: 2,
},
const renderData: RenderData[] = [];
walkTree(root, 'children', container, 100, 0, 1, 100, (item, x, y, width, height, label, collapsed) => {
renderData.push({ item, x, y, width, height, label, collapsed });
});
expect(renderData).toEqual([
{ item: root, width: 99, height: 22, x: 0, y: 0, collapsed: false, label: '1' },
{ item: root.children[0], width: 49, height: 22, x: 0, y: 22, collapsed: false, label: '2' },
{ item: root.children[1], width: 49, height: 22, x: 50, y: 22, collapsed: false, label: '3' },
]);
});
it('should render a collapsed items', () => {
const level: LevelItem[] = [
{ start: 0, itemIndexes: [0], children: [], value: 100 },
{ start: 100, itemIndexes: [1], children: [], value: 2 },
{ start: 102, itemIndexes: [2], children: [], value: 1 },
];
const root: LevelItem = {
start: 0,
itemIndexes: [0],
value: 100,
level: 0,
children: [
{ start: 0, itemIndexes: [1], children: [], value: 1, level: 1 },
{ start: 1, itemIndexes: [2], children: [], value: 1, level: 1 },
],
};
const container = new FlameGraphDataContainer(
makeDataFrame({ value: [100, 2, 1], level: [2, 2, 2], label: ['1', '2', '3'], self: [0, 0, 0] })
makeDataFrame({ value: [100, 1, 1], level: [0, 1, 1], label: ['1', '2', '3'], self: [0, 1, 1] })
);
const result = getRectDimensionsForLevel(container, level, 2, 100, 0, 1);
expect(result).toEqual([
{ width: 99, height: 22, x: 0, y: 44, collapsed: false, ticks: 100, label: '1', unitLabel: '100', itemIndex: 0 },
{ width: 3, height: 22, x: 100, y: 44, collapsed: true, ticks: 3, label: '2', unitLabel: '2', itemIndex: 1 },
const renderData: RenderData[] = [];
walkTree(root, 'children', container, 100, 0, 1, 100, (item, x, y, width, height, label, collapsed) => {
renderData.push({ item, x, y, width, height, label, collapsed });
});
expect(renderData).toEqual([
{ item: root, width: 99, height: 22, x: 0, y: 0, collapsed: false, label: '1' },
{ item: root.children[0], width: 1, height: 22, x: 0, y: 22, collapsed: true, label: '2' },
{ item: root.children[1], width: 1, height: 22, x: 1, y: 22, collapsed: true, label: '3' },
]);
});
it('skips too small items', () => {
const root: LevelItem = {
start: 0,
itemIndexes: [0],
value: 100,
level: 0,
children: [
{ start: 0, itemIndexes: [1], children: [], value: 0.1, level: 1 },
{ start: 1, itemIndexes: [2], children: [], value: 0.1, level: 1 },
],
};
const container = new FlameGraphDataContainer(
makeDataFrame({ value: [100, 0.1, 0.1], level: [0, 1, 1], label: ['1', '2', '3'], self: [0, 0.1, 0.1] })
);
const renderData: RenderData[] = [];
walkTree(root, 'children', container, 100, 0, 1, 100, (item, x, y, width, height, label, collapsed) => {
renderData.push({ item, x, y, width, height, label, collapsed });
});
expect(renderData).toEqual([{ item: root, width: 99, height: 22, x: 0, y: 0, collapsed: false, label: '1' }]);
});
});

View File

@ -23,7 +23,11 @@ const ufuzzy = new uFuzzy();
type RenderOptions = {
canvasRef: RefObject<HTMLCanvasElement>;
data: FlameGraphDataContainer;
levels: LevelItem[][];
root: LevelItem;
direction: 'children' | 'parents';
// Depth in number of levels
depth: number;
wrapperWidth: number;
// If we are rendering only zoomed in part of the graph.
@ -48,7 +52,9 @@ export function useFlameRender(options: RenderOptions) {
const {
canvasRef,
data,
levels,
root,
depth,
direction,
wrapperWidth,
rangeMin,
rangeMax,
@ -60,7 +66,130 @@ export function useFlameRender(options: RenderOptions) {
colorScheme,
focusedItemData,
} = options;
const foundLabels = useMemo(() => {
const foundLabels = useFoundLabels(search, data);
const ctx = useSetupCanvas(canvasRef, wrapperWidth, depth);
const theme = useTheme2();
// There is a bit of dependency injections here that does not add readability, mainly to prevent recomputing some
// common stuff for all the nodes in the graph when only once is enough. perf/readability tradeoff.
const getBarColor = useColorFunction(
totalColorTicks,
totalTicksRight,
colorScheme,
theme,
rangeMin,
rangeMax,
foundLabels,
focusedItemData ? focusedItemData.item.level : 0
);
const renderFunc = useRenderFunc(ctx, data, getBarColor, textAlign);
useEffect(() => {
if (!ctx) {
return;
}
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
walkTree(root, direction, data, totalViewTicks, rangeMin, rangeMax, wrapperWidth, renderFunc);
}, [ctx, data, root, wrapperWidth, rangeMin, rangeMax, totalViewTicks, direction, renderFunc]);
}
type RenderFunc = (
item: LevelItem,
x: number,
y: number,
width: number,
height: number,
label: string,
// Collapsed means the width is too small to show the label, and we group collapsed siblings together.
collapsed: boolean
) => void;
function useRenderFunc(
ctx: CanvasRenderingContext2D | undefined,
data: FlameGraphDataContainer,
getBarColor: (item: LevelItem, label: string, collapsed: boolean) => string,
textAlign: TextAlign
): RenderFunc {
return useMemo(() => {
if (!ctx) {
return () => {};
}
return (item, x, y, width, height, label, collapsed) => {
ctx.beginPath();
ctx.rect(x + (collapsed ? 0 : BAR_BORDER_WIDTH), y, width, height);
ctx.fillStyle = getBarColor(item, label, collapsed);
if (collapsed) {
// Only fill the collapsed rects
ctx.fill();
} else {
ctx.stroke();
ctx.fill();
if (width >= LABEL_THRESHOLD) {
renderLabel(ctx, data, label, item, width, x, y, textAlign);
}
}
};
}, [ctx, getBarColor, textAlign, data]);
}
/**
* Exported for testing don't use directly
* Walks the tree and computes coordinates, dimensions and other data needed for rendering. For each item in the tree
* it defers the rendering to the renderFunc.
*/
export function walkTree(
root: LevelItem,
// In sandwich view we use parents direction to show all callers.
direction: 'children' | 'parents',
data: FlameGraphDataContainer,
totalViewTicks: number,
rangeMin: number,
rangeMax: number,
wrapperWidth: number,
renderFunc: RenderFunc
) {
const stack: LevelItem[] = [];
stack.push(root);
const pixelsPerTick = (wrapperWidth * window.devicePixelRatio) / totalViewTicks / (rangeMax - rangeMin);
while (stack.length > 0) {
const item = stack.shift()!;
let curBarTicks = item.value;
// Multiple collapsed items are shown as a single gray bar
const collapsed = curBarTicks * pixelsPerTick <= COLLAPSE_THRESHOLD;
const width = curBarTicks * pixelsPerTick - (collapsed ? 0 : BAR_BORDER_WIDTH * 2);
const height = PIXELS_PER_LEVEL;
if (width < HIDE_THRESHOLD) {
// We don't render nor it's children
continue;
}
const barX = getBarX(item.start, totalViewTicks, rangeMin, pixelsPerTick);
const barY = item.level * PIXELS_PER_LEVEL;
let label = data.getLabel(item.itemIndexes[0]);
renderFunc(item, barX, barY, width, height, label, collapsed);
const nextList = direction === 'children' ? item.children : item.parents;
if (nextList) {
stack.unshift(...nextList);
}
}
}
/**
* Based on the search string it does a fuzzy search over all the unique labels so we can highlight them later.
*/
function useFoundLabels(search: string | undefined, data: FlameGraphDataContainer): Set<string> | undefined {
return useMemo(() => {
if (search) {
const foundLabels = new Set<string>();
let idxs = ufuzzy.filter(data.getUniqueLabels(), search);
@ -76,58 +205,50 @@ export function useFlameRender(options: RenderOptions) {
// In this case undefined means there was no search so no attempt to highlighting anything should be made.
return undefined;
}, [search, data]);
}
const ctx = useSetupCanvas(canvasRef, wrapperWidth, levels.length);
const theme = useTheme2();
function useColorFunction(
totalTicks: number,
totalTicksRight: number | undefined,
colorScheme: ColorScheme | ColorSchemeDiff,
theme: GrafanaTheme2,
rangeMin: number,
rangeMax: number,
foundNames: Set<string> | undefined,
topLevel: number
) {
return useMemo(() => {
// We use the same color for all muted bars so let's do it just once and reuse the result in the closure of the
// returned function.
const barMutedColor = color(theme.colors.background.secondary);
const barMutedColorHex = theme.isLight
? barMutedColor.darken(10).toHexString()
: barMutedColor.lighten(10).toHexString();
useEffect(() => {
if (!ctx) {
return;
}
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const pixelsPerTick = (wrapperWidth * window.devicePixelRatio) / totalViewTicks / (rangeMax - rangeMin);
for (let levelIndex = 0; levelIndex < levels.length; levelIndex++) {
const level = levels[levelIndex];
// Get all the dimensions of the rectangles for the level. We do this by level instead of per rectangle, because
// sometimes we collapse multiple bars into single rect.
const dimensions = getRectDimensionsForLevel(data, level, levelIndex, totalViewTicks, rangeMin, pixelsPerTick);
for (const rect of dimensions) {
const focusedLevel = focusedItemData ? focusedItemData.level : 0;
// Render each rectangle based on the computed dimensions
renderRect(
ctx,
rect,
totalColorTicks,
totalTicksRight,
rangeMin,
rangeMax,
levelIndex,
focusedLevel,
foundLabels,
textAlign,
colorScheme,
theme
);
return function getColor(item: LevelItem, label: string, collapsed: boolean) {
// If collapsed and no search we can quickly return the muted color
if (collapsed && !foundNames) {
// Collapsed are always grayed
return barMutedColorHex;
}
}
}, [
ctx,
data,
levels,
wrapperWidth,
rangeMin,
rangeMax,
search,
focusedItemData,
foundLabels,
textAlign,
totalViewTicks,
totalColorTicks,
totalTicksRight,
colorScheme,
theme,
]);
const barColor =
item.valueRight !== undefined &&
(colorScheme === ColorSchemeDiff.Default || colorScheme === ColorSchemeDiff.DiffColorBlind)
? getBarColorByDiff(item.value, item.valueRight!, totalTicks, totalTicksRight!, colorScheme)
: colorScheme === ColorScheme.ValueBased
? getBarColorByValue(item.value, totalTicks, rangeMin, rangeMax)
: getBarColorByPackage(label, theme);
if (foundNames) {
// Means we are searching, we use color for matches and gray the rest
return foundNames.has(label) ? barColor.toHslString() : barMutedColorHex;
}
// Mute if we are above the focused symbol
return item.level > topLevel - 1 ? barColor.toHslString() : barColor.lighten(15).toHslString();
};
}, [totalTicks, totalTicksRight, colorScheme, theme, rangeMin, rangeMax, foundNames, topLevel]);
}
function useSetupCanvas(canvasRef: RefObject<HTMLCanvasElement>, wrapperWidth: number, numberOfLevels: number) {
@ -153,146 +274,31 @@ function useSetupCanvas(canvasRef: RefObject<HTMLCanvasElement>, wrapperWidth: n
return ctx;
}
type RectData = {
width: number;
height: number;
x: number;
y: number;
collapsed: boolean;
ticks: number;
ticksRight?: number;
label: string;
unitLabel: string;
itemIndex: number;
};
/**
* Compute the pixel coordinates for each bar in a level. We need full level of bars so that we can collapse small bars
* into bigger rects.
*/
export function getRectDimensionsForLevel(
data: FlameGraphDataContainer,
level: LevelItem[],
levelIndex: number,
totalTicks: number,
rangeMin: number,
pixelsPerTick: number
): RectData[] {
const coordinatesLevel = [];
for (let barIndex = 0; barIndex < level.length; barIndex += 1) {
const item = level[barIndex];
const barX = getBarX(item.start, totalTicks, rangeMin, pixelsPerTick);
let curBarTicks = item.value;
// merge very small blocks into big "collapsed" ones for performance
const collapsed = curBarTicks * pixelsPerTick <= COLLAPSE_THRESHOLD;
if (collapsed) {
while (
barIndex < level.length - 1 &&
item.start + curBarTicks === level[barIndex + 1].start &&
level[barIndex + 1].value * pixelsPerTick <= COLLAPSE_THRESHOLD
) {
barIndex += 1;
curBarTicks += level[barIndex].value;
}
}
const displayValue = data.valueDisplayProcessor(item.value);
let unit = displayValue.suffix ? displayValue.text + displayValue.suffix : displayValue.text;
const width = curBarTicks * pixelsPerTick - (collapsed ? 0 : BAR_BORDER_WIDTH * 2);
coordinatesLevel.push({
width,
height: PIXELS_PER_LEVEL,
x: barX,
y: levelIndex * PIXELS_PER_LEVEL,
collapsed,
ticks: curBarTicks,
// When collapsed this does not make that much sense but then we don't really use it anyway.
ticksRight: item.valueRight,
label: data.getLabel(item.itemIndexes[0]),
unitLabel: unit,
itemIndex: item.itemIndexes[0],
});
}
return coordinatesLevel;
}
export function renderRect(
ctx: CanvasRenderingContext2D,
rect: RectData,
totalTicks: number,
totalTicksRight: number | undefined,
rangeMin: number,
rangeMax: number,
levelIndex: number,
topLevelIndex: number,
foundNames: Set<string> | undefined,
textAlign: TextAlign,
colorScheme: ColorScheme | ColorSchemeDiff,
theme: GrafanaTheme2
) {
if (rect.width < HIDE_THRESHOLD) {
return;
}
ctx.beginPath();
ctx.rect(rect.x + (rect.collapsed ? 0 : BAR_BORDER_WIDTH), rect.y, rect.width, rect.height);
const barColor =
rect.ticksRight !== undefined &&
(colorScheme === ColorSchemeDiff.Default || colorScheme === ColorSchemeDiff.DiffColorBlind)
? getBarColorByDiff(rect.ticks, rect.ticksRight, totalTicks, totalTicksRight!, colorScheme)
: colorScheme === ColorScheme.ValueBased
? getBarColorByValue(rect.ticks, totalTicks, rangeMin, rangeMax)
: getBarColorByPackage(rect.label, theme);
const barMutedColor = color(theme.colors.background.secondary);
const barMutedColorHex = theme.isLight
? barMutedColor.darken(10).toHexString()
: barMutedColor.lighten(10).toHexString();
if (foundNames) {
// Means we are searching, we use color for matches and gray the rest
ctx.fillStyle = foundNames.has(rect.label) ? barColor.toHslString() : barMutedColorHex;
} else {
// No search
if (rect.collapsed) {
// Collapsed are always grayed
ctx.fillStyle = barMutedColorHex;
} else {
// Mute if we are above the focused symbol
ctx.fillStyle = levelIndex > topLevelIndex - 1 ? barColor.toHslString() : barColor.lighten(15).toHslString();
}
}
if (rect.collapsed) {
// Only fill the collapsed rects
ctx.fill();
return;
}
ctx.stroke();
ctx.fill();
if (rect.width >= LABEL_THRESHOLD) {
renderLabel(ctx, rect.label, rect, textAlign);
}
}
// Renders a text inside the node rectangle. It allows setting alignment of the text left or right which takes effect
// when text is too long to fit in the rectangle.
function renderLabel(ctx: CanvasRenderingContext2D, name: string, rect: RectData, textAlign: TextAlign) {
function renderLabel(
ctx: CanvasRenderingContext2D,
data: FlameGraphDataContainer,
label: string,
item: LevelItem,
width: number,
x: number,
y: number,
textAlign: TextAlign
) {
ctx.save();
ctx.clip(); // so text does not overflow
ctx.fillStyle = '#222';
// We only measure name here instead of full label because of how we deal with the units and aligning later.
const measure = ctx.measureText(name);
const spaceForTextInRect = rect.width - BAR_TEXT_PADDING_LEFT;
const displayValue = data.valueDisplayProcessor(item.value);
const unit = displayValue.suffix ? displayValue.text + displayValue.suffix : displayValue.text;
let label = `${name} (${rect.unitLabel})`;
let labelX = Math.max(rect.x, 0) + BAR_TEXT_PADDING_LEFT;
// We only measure name here instead of full label because of how we deal with the units and aligning later.
const measure = ctx.measureText(label);
const spaceForTextInRect = width - BAR_TEXT_PADDING_LEFT;
let fullLabel = `${label} (${unit})`;
let labelX = Math.max(x, 0) + BAR_TEXT_PADDING_LEFT;
// We use the desired alignment only if there is not enough space for the text, otherwise we keep left alignment as
// that will already show full text.
@ -301,12 +307,12 @@ function renderLabel(ctx: CanvasRenderingContext2D, name: string, rect: RectData
// If aligned to the right we don't want to take the space with the unit label as the assumption is user wants to
// mainly see the name. This also reflects how pyro/flamegraph works.
if (textAlign === 'right') {
label = name;
labelX = rect.x + rect.width - BAR_TEXT_PADDING_LEFT;
fullLabel = label;
labelX = x + width - BAR_TEXT_PADDING_LEFT;
}
}
ctx.fillText(label, labelX, rect.y + PIXELS_PER_LEVEL / 2);
ctx.fillText(fullLabel, labelX, y + PIXELS_PER_LEVEL / 2);
ctx.restore();
}

View File

@ -10,16 +10,16 @@ describe('textToDataContainer', () => {
[6]
`)!;
const n6: LevelItem = { itemIndexes: [5], start: 3, children: [], value: 3 };
const n6: LevelItem = { itemIndexes: [5], start: 3, children: [], value: 3, level: 3 };
const n5: LevelItem = { itemIndexes: [4], start: 3, children: [n6], value: 3 };
const n3: LevelItem = { itemIndexes: [2], start: 0, children: [], value: 3 };
const n5: LevelItem = { itemIndexes: [4], start: 3, children: [n6], value: 3, level: 2 };
const n3: LevelItem = { itemIndexes: [2], start: 0, children: [], value: 3, level: 2 };
const n7: LevelItem = { itemIndexes: [6], start: 8, children: [], value: 6 };
const n4: LevelItem = { itemIndexes: [3], start: 3, children: [n5], value: 5 };
const n2: LevelItem = { itemIndexes: [1], start: 0, children: [n3], value: 3 };
const n7: LevelItem = { itemIndexes: [6], start: 8, children: [], value: 6, level: 1 };
const n4: LevelItem = { itemIndexes: [3], start: 3, children: [n5], value: 5, level: 1 };
const n2: LevelItem = { itemIndexes: [1], start: 0, children: [n3], value: 3, level: 1 };
const n1: LevelItem = { itemIndexes: [0], start: 0, children: [n2, n4, n7], value: 17 };
const n1: LevelItem = { itemIndexes: [0], start: 0, children: [n2, n4, n7], value: 17, level: 0 };
n2.parents = [n1];
n4.parents = [n1];

View File

@ -43,6 +43,7 @@ export function textToDataContainer(text: string) {
itemIndexes: [dfValues.length - 1],
start: match.index - leftMargin,
children: [],
level: i,
};
itemLevels[i] = itemLevels[i] || [];

View File

@ -91,6 +91,7 @@ export function mergeSubtrees(
children: [],
parents: [],
start: 0,
level: args.level,
};
levels[args.level] = levels[args.level] || [];
@ -119,6 +120,11 @@ export function mergeSubtrees(
// Reverse the levels if we are doing callers tree, so we return levels in the correct order.
if (direction === 'parents') {
levels.reverse();
levels.forEach((level, index) => {
level.forEach((item) => {
item.level = index;
});
});
}
return levels;

View File

@ -85,7 +85,6 @@ const FlameGraphContainer = ({
}
return new FlameGraphDataContainer(data, theme);
}, [data, theme]);
const [colorScheme, setColorScheme] = useColorScheme(dataContainer);
const styles = getStyles(theme, vertical);

View File

@ -5,7 +5,6 @@ export type ClickedItemData = {
posY: number;
label: string;
item: LevelItem;
level: number;
};
export enum SampleUnit {