mirror of
https://github.com/grafana/grafana.git
synced 2025-02-16 18:34:52 -06:00
Flamegraph: Rendering from tree instead of levels (#76215)
This commit is contained in:
parent
43add83d1a
commit
c99b978857
@ -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;
|
||||
|
259
packages/grafana-flamegraph/src/FlameGraph/FlameGraphCanvas.tsx
Normal file
259
packages/grafana-flamegraph/src/FlameGraph/FlameGraphCanvas.tsx
Normal 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;
|
@ -45,8 +45,8 @@ describe('FlameGraphMetadata', () => {
|
||||
children: [],
|
||||
itemIndexes: [3],
|
||||
start: 3,
|
||||
level: 0,
|
||||
},
|
||||
level: 0,
|
||||
posX: 0,
|
||||
posY: 0,
|
||||
},
|
||||
|
@ -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([
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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];
|
||||
|
@ -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)]) {
|
||||
|
@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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];
|
||||
|
@ -43,6 +43,7 @@ export function textToDataContainer(text: string) {
|
||||
itemIndexes: [dfValues.length - 1],
|
||||
start: match.index - leftMargin,
|
||||
children: [],
|
||||
level: i,
|
||||
};
|
||||
|
||||
itemLevels[i] = itemLevels[i] || [];
|
||||
|
@ -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;
|
||||
|
@ -85,7 +85,6 @@ const FlameGraphContainer = ({
|
||||
}
|
||||
return new FlameGraphDataContainer(data, theme);
|
||||
}, [data, theme]);
|
||||
|
||||
const [colorScheme, setColorScheme] = useColorScheme(dataContainer);
|
||||
const styles = getStyles(theme, vertical);
|
||||
|
||||
|
@ -5,7 +5,6 @@ export type ClickedItemData = {
|
||||
posY: number;
|
||||
label: string;
|
||||
item: LevelItem;
|
||||
level: number;
|
||||
};
|
||||
|
||||
export enum SampleUnit {
|
||||
|
Loading…
Reference in New Issue
Block a user