2022-10-07 11:39:14 +01:00
|
|
|
// This component is based on logic from the flamebearer project
|
|
|
|
|
// https://github.com/mapbox/flamebearer
|
|
|
|
|
|
|
|
|
|
// ISC License
|
|
|
|
|
|
|
|
|
|
// Copyright (c) 2018, Mapbox
|
|
|
|
|
|
|
|
|
|
// Permission to use, copy, modify, and/or distribute this software for any purpose
|
|
|
|
|
// with or without fee is hereby granted, provided that the above copyright notice
|
|
|
|
|
// and this permission notice appear in all copies.
|
|
|
|
|
|
|
|
|
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
|
|
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
|
|
|
|
// FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
|
|
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
|
|
|
|
// 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';
|
2023-01-20 12:57:31 +00:00
|
|
|
import uFuzzy from '@leeoniya/ufuzzy';
|
2023-03-14 15:41:27 +01:00
|
|
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
2022-10-07 11:39:14 +01:00
|
|
|
import { useMeasure } from 'react-use';
|
|
|
|
|
|
2023-03-30 11:32:44 +02:00
|
|
|
import { CoreApp } from '@grafana/data';
|
2023-04-13 15:27:01 +02:00
|
|
|
import { useStyles2 } from '@grafana/ui';
|
2022-10-07 11:39:14 +01:00
|
|
|
|
|
|
|
|
import { PIXELS_PER_LEVEL } from '../../constants';
|
2023-05-25 11:08:03 +02:00
|
|
|
import { SelectedView, ContextMenuData, TextAlign } from '../types';
|
2022-10-07 11:39:14 +01:00
|
|
|
|
2023-03-02 09:47:56 +00:00
|
|
|
import FlameGraphContextMenu from './FlameGraphContextMenu';
|
2023-01-30 14:02:26 +00:00
|
|
|
import FlameGraphMetadata from './FlameGraphMetadata';
|
2023-03-30 11:32:44 +02:00
|
|
|
import FlameGraphTooltip from './FlameGraphTooltip';
|
|
|
|
|
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
|
2022-10-07 11:39:14 +01:00
|
|
|
import { getBarX, getRectDimensionsForLevel, renderRect } from './rendering';
|
|
|
|
|
|
|
|
|
|
type Props = {
|
2023-03-30 11:32:44 +02:00
|
|
|
data: FlameGraphDataContainer;
|
2022-10-20 14:20:48 +01:00
|
|
|
app: CoreApp;
|
2023-03-30 11:32:44 +02:00
|
|
|
levels: LevelItem[][];
|
2022-10-07 11:39:14 +01:00
|
|
|
topLevelIndex: number;
|
2023-01-30 14:02:26 +00:00
|
|
|
selectedBarIndex: number;
|
2022-10-07 11:39:14 +01:00
|
|
|
rangeMin: number;
|
|
|
|
|
rangeMax: number;
|
|
|
|
|
search: string;
|
|
|
|
|
setTopLevelIndex: (level: number) => void;
|
2023-01-30 14:02:26 +00:00
|
|
|
setSelectedBarIndex: (bar: number) => void;
|
2022-10-07 11:39:14 +01:00
|
|
|
setRangeMin: (range: number) => void;
|
|
|
|
|
setRangeMax: (range: number) => void;
|
|
|
|
|
selectedView: SelectedView;
|
|
|
|
|
style?: React.CSSProperties;
|
2023-05-25 11:08:03 +02:00
|
|
|
textAlign: TextAlign;
|
2022-10-07 11:39:14 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const FlameGraph = ({
|
|
|
|
|
data,
|
2022-10-20 14:20:48 +01:00
|
|
|
app,
|
2022-10-07 11:39:14 +01:00
|
|
|
levels,
|
|
|
|
|
topLevelIndex,
|
2023-01-30 14:02:26 +00:00
|
|
|
selectedBarIndex,
|
2022-10-07 11:39:14 +01:00
|
|
|
rangeMin,
|
|
|
|
|
rangeMax,
|
|
|
|
|
search,
|
|
|
|
|
setTopLevelIndex,
|
2023-01-30 14:02:26 +00:00
|
|
|
setSelectedBarIndex,
|
2022-10-07 11:39:14 +01:00
|
|
|
setRangeMin,
|
|
|
|
|
setRangeMax,
|
|
|
|
|
selectedView,
|
2023-05-25 11:08:03 +02:00
|
|
|
textAlign,
|
2022-10-07 11:39:14 +01:00
|
|
|
}: Props) => {
|
2023-04-13 15:27:01 +02:00
|
|
|
const styles = useStyles2(getStyles);
|
2023-03-30 11:32:44 +02:00
|
|
|
const totalTicks = data.getValue(0);
|
2022-10-07 11:39:14 +01:00
|
|
|
|
|
|
|
|
const [sizeRef, { width: wrapperWidth }] = useMeasure<HTMLDivElement>();
|
|
|
|
|
const graphRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
2023-03-30 11:32:44 +02:00
|
|
|
const [tooltipItem, setTooltipItem] = useState<LevelItem>();
|
2023-03-02 09:47:56 +00:00
|
|
|
const [contextMenuData, setContextMenuData] = useState<ContextMenuData>();
|
2022-10-07 11:39:14 +01:00
|
|
|
|
2023-02-02 09:09:48 -06:00
|
|
|
const [ufuzzy] = useState(() => {
|
|
|
|
|
return new uFuzzy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const foundLabels = useMemo(() => {
|
|
|
|
|
const foundLabels = new Set<string>();
|
|
|
|
|
|
|
|
|
|
if (search) {
|
2023-03-30 11:32:44 +02:00
|
|
|
let idxs = ufuzzy.filter(data.getUniqueLabels(), search);
|
2023-03-10 05:49:02 -06:00
|
|
|
|
|
|
|
|
if (idxs) {
|
|
|
|
|
for (let idx of idxs) {
|
2023-03-30 11:32:44 +02:00
|
|
|
foundLabels.add(data.getUniqueLabels()[idx]);
|
2023-03-10 05:49:02 -06:00
|
|
|
}
|
2023-02-02 09:09:48 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return foundLabels;
|
2023-03-30 11:32:44 +02:00
|
|
|
}, [ufuzzy, search, data]);
|
2023-02-02 09:09:48 -06:00
|
|
|
|
2023-03-14 15:41:27 +01:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!levels.length) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const pixelsPerTick = (wrapperWidth * window.devicePixelRatio) / totalTicks / (rangeMax - rangeMin);
|
|
|
|
|
const ctx = graphRef.current?.getContext('2d')!;
|
|
|
|
|
const graph = graphRef.current!;
|
|
|
|
|
|
|
|
|
|
const height = PIXELS_PER_LEVEL * levels.length;
|
|
|
|
|
graph.width = Math.round(wrapperWidth * window.devicePixelRatio);
|
|
|
|
|
graph.height = Math.round(height * window.devicePixelRatio);
|
|
|
|
|
graph.style.width = `${wrapperWidth}px`;
|
|
|
|
|
graph.style.height = `${height}px`;
|
|
|
|
|
|
|
|
|
|
ctx.textBaseline = 'middle';
|
|
|
|
|
ctx.font = 12 * window.devicePixelRatio + 'px monospace';
|
|
|
|
|
ctx.strokeStyle = 'white';
|
|
|
|
|
|
|
|
|
|
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.
|
2023-03-30 11:32:44 +02:00
|
|
|
const dimensions = getRectDimensionsForLevel(data, level, levelIndex, totalTicks, rangeMin, pixelsPerTick);
|
2023-03-14 15:41:27 +01:00
|
|
|
for (const rect of dimensions) {
|
|
|
|
|
// Render each rectangle based on the computed dimensions
|
2023-05-25 11:08:03 +02:00
|
|
|
renderRect(
|
|
|
|
|
ctx,
|
|
|
|
|
rect,
|
|
|
|
|
totalTicks,
|
|
|
|
|
rangeMin,
|
|
|
|
|
rangeMax,
|
|
|
|
|
search,
|
|
|
|
|
levelIndex,
|
|
|
|
|
topLevelIndex,
|
|
|
|
|
foundLabels,
|
|
|
|
|
textAlign
|
|
|
|
|
);
|
2022-10-07 11:39:14 +01:00
|
|
|
}
|
2023-03-14 15:41:27 +01:00
|
|
|
}
|
2023-05-25 11:08:03 +02:00
|
|
|
}, [data, levels, wrapperWidth, totalTicks, rangeMin, rangeMax, search, topLevelIndex, foundLabels, textAlign]);
|
2022-10-07 11:39:14 +01:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (graphRef.current) {
|
|
|
|
|
graphRef.current.onclick = (e) => {
|
2023-03-30 11:32:44 +02:00
|
|
|
setTooltipItem(undefined);
|
2022-10-07 11:39:14 +01:00
|
|
|
const pixelsPerTick = graphRef.current!.clientWidth / totalTicks / (rangeMax - rangeMin);
|
2023-03-02 09:47:56 +00:00
|
|
|
const { levelIndex, barIndex } = convertPixelCoordinatesToBarCoordinates(
|
2023-03-30 11:32:44 +02:00
|
|
|
data,
|
2023-03-02 09:47:56 +00:00
|
|
|
e,
|
|
|
|
|
pixelsPerTick,
|
|
|
|
|
levels,
|
|
|
|
|
totalTicks,
|
|
|
|
|
rangeMin
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// if clicking on a block in the canvas
|
2022-10-07 11:39:14 +01:00
|
|
|
if (barIndex !== -1 && !isNaN(levelIndex) && !isNaN(barIndex)) {
|
2023-03-02 09:47:56 +00:00
|
|
|
setContextMenuData({ e, levelIndex, barIndex });
|
|
|
|
|
} else {
|
|
|
|
|
// if clicking on the canvas but there is no block beneath the cursor
|
|
|
|
|
setContextMenuData(undefined);
|
2022-10-07 11:39:14 +01:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
graphRef.current!.onmousemove = (e) => {
|
2023-03-02 09:47:56 +00:00
|
|
|
if (tooltipRef.current && contextMenuData === undefined) {
|
2023-03-30 11:32:44 +02:00
|
|
|
setTooltipItem(undefined);
|
2022-10-07 11:39:14 +01:00
|
|
|
const pixelsPerTick = graphRef.current!.clientWidth / totalTicks / (rangeMax - rangeMin);
|
2023-03-02 09:47:56 +00:00
|
|
|
const { levelIndex, barIndex } = convertPixelCoordinatesToBarCoordinates(
|
2023-03-30 11:32:44 +02:00
|
|
|
data,
|
2023-03-02 09:47:56 +00:00
|
|
|
e,
|
|
|
|
|
pixelsPerTick,
|
|
|
|
|
levels,
|
|
|
|
|
totalTicks,
|
|
|
|
|
rangeMin
|
|
|
|
|
);
|
2022-10-07 11:39:14 +01:00
|
|
|
|
|
|
|
|
if (barIndex !== -1 && !isNaN(levelIndex) && !isNaN(barIndex)) {
|
2022-10-25 17:55:49 +02:00
|
|
|
tooltipRef.current.style.top = e.clientY + 'px';
|
2023-05-11 16:08:16 +02:00
|
|
|
if (document.documentElement.clientWidth - e.clientX < 400) {
|
|
|
|
|
tooltipRef.current.style.right = document.documentElement.clientWidth - e.clientX + 15 + 'px';
|
|
|
|
|
tooltipRef.current.style.left = 'auto';
|
|
|
|
|
} else {
|
|
|
|
|
tooltipRef.current.style.left = e.clientX + 15 + 'px';
|
|
|
|
|
tooltipRef.current.style.right = 'auto';
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-30 11:32:44 +02:00
|
|
|
setTooltipItem(levels[levelIndex][barIndex]);
|
2022-10-07 11:39:14 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
graphRef.current!.onmouseleave = () => {
|
2023-03-30 11:32:44 +02:00
|
|
|
setTooltipItem(undefined);
|
2022-10-07 11:39:14 +01:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}, [
|
2023-03-30 11:32:44 +02:00
|
|
|
data,
|
2022-10-07 11:39:14 +01:00
|
|
|
levels,
|
|
|
|
|
rangeMin,
|
|
|
|
|
rangeMax,
|
|
|
|
|
topLevelIndex,
|
|
|
|
|
totalTicks,
|
|
|
|
|
wrapperWidth,
|
|
|
|
|
setTopLevelIndex,
|
|
|
|
|
setRangeMin,
|
|
|
|
|
setRangeMax,
|
|
|
|
|
selectedView,
|
2023-01-30 14:02:26 +00:00
|
|
|
setSelectedBarIndex,
|
2023-03-02 09:47:56 +00:00
|
|
|
setContextMenuData,
|
|
|
|
|
contextMenuData,
|
2022-10-07 11:39:14 +01:00
|
|
|
]);
|
|
|
|
|
|
2023-03-30 11:32:44 +02:00
|
|
|
// hide context menu if outside the flame graph canvas is clicked
|
2023-03-02 09:47:56 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
const handleOnClick = (e: MouseEvent) => {
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
|
|
|
if ((e.target as HTMLElement).parentElement?.id !== 'flameGraphCanvasContainer') {
|
|
|
|
|
setContextMenuData(undefined);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener('click', handleOnClick);
|
|
|
|
|
return () => window.removeEventListener('click', handleOnClick);
|
|
|
|
|
}, [setContextMenuData]);
|
|
|
|
|
|
2022-10-07 11:39:14 +01:00
|
|
|
return (
|
|
|
|
|
<div className={styles.graph} ref={sizeRef}>
|
2023-01-30 14:02:26 +00:00
|
|
|
<FlameGraphMetadata
|
2023-03-30 11:32:44 +02:00
|
|
|
data={data}
|
2023-01-30 14:02:26 +00:00
|
|
|
levels={levels}
|
|
|
|
|
topLevelIndex={topLevelIndex}
|
|
|
|
|
selectedBarIndex={selectedBarIndex}
|
|
|
|
|
totalTicks={totalTicks}
|
|
|
|
|
/>
|
2023-03-02 09:47:56 +00:00
|
|
|
<div className={styles.canvasContainer} id="flameGraphCanvasContainer">
|
|
|
|
|
<canvas ref={graphRef} data-testid="flameGraph" />
|
|
|
|
|
</div>
|
2023-03-30 11:32:44 +02:00
|
|
|
<FlameGraphTooltip tooltipRef={tooltipRef} item={tooltipItem} data={data} totalTicks={totalTicks} />
|
2023-03-02 09:47:56 +00:00
|
|
|
{contextMenuData && (
|
|
|
|
|
<FlameGraphContextMenu
|
2023-03-30 11:32:44 +02:00
|
|
|
data={data}
|
2023-03-02 09:47:56 +00:00
|
|
|
contextMenuData={contextMenuData!}
|
|
|
|
|
levels={levels}
|
|
|
|
|
totalTicks={totalTicks}
|
|
|
|
|
graphRef={graphRef}
|
|
|
|
|
setContextMenuData={setContextMenuData}
|
|
|
|
|
setTopLevelIndex={setTopLevelIndex}
|
|
|
|
|
setSelectedBarIndex={setSelectedBarIndex}
|
|
|
|
|
setRangeMin={setRangeMin}
|
|
|
|
|
setRangeMax={setRangeMax}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2022-10-07 11:39:14 +01:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2023-04-13 15:27:01 +02:00
|
|
|
const getStyles = () => ({
|
2022-10-07 11:39:14 +01:00
|
|
|
graph: css`
|
2022-10-20 14:20:48 +01:00
|
|
|
overflow: scroll;
|
2023-04-13 15:27:01 +02:00
|
|
|
height: 100%;
|
|
|
|
|
flex-grow: 1;
|
|
|
|
|
flex-basis: 50%;
|
2022-10-07 11:39:14 +01:00
|
|
|
`,
|
2023-03-02 09:47:56 +00:00
|
|
|
canvasContainer: css`
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
`,
|
2022-10-07 11:39:14 +01:00
|
|
|
});
|
|
|
|
|
|
2023-03-02 09:47:56 +00:00
|
|
|
// Convert pixel coordinates to bar coordinates in the levels array so that we can add mouse events like clicks to
|
|
|
|
|
// the canvas.
|
|
|
|
|
const convertPixelCoordinatesToBarCoordinates = (
|
2023-03-30 11:32:44 +02:00
|
|
|
data: FlameGraphDataContainer,
|
2023-03-02 09:47:56 +00:00
|
|
|
e: MouseEvent,
|
|
|
|
|
pixelsPerTick: number,
|
2023-03-30 11:32:44 +02:00
|
|
|
levels: LevelItem[][],
|
2023-03-02 09:47:56 +00:00
|
|
|
totalTicks: number,
|
|
|
|
|
rangeMin: number
|
|
|
|
|
) => {
|
|
|
|
|
const levelIndex = Math.floor(e.offsetY / (PIXELS_PER_LEVEL / window.devicePixelRatio));
|
2023-03-30 11:32:44 +02:00
|
|
|
const barIndex = getBarIndex(e.offsetX, data, levels[levelIndex], pixelsPerTick, totalTicks, rangeMin);
|
2023-03-02 09:47:56 +00:00
|
|
|
return { levelIndex, barIndex };
|
|
|
|
|
};
|
|
|
|
|
|
2022-10-07 11:39:14 +01:00
|
|
|
/**
|
|
|
|
|
* 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,
|
2023-03-30 11:32:44 +02:00
|
|
|
data: FlameGraphDataContainer,
|
|
|
|
|
level: LevelItem[],
|
2022-10-07 11:39:14 +01:00
|
|
|
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(
|
2023-03-30 11:32:44 +02:00
|
|
|
level[midIndex].start + data.getValue(level[midIndex].itemIndex),
|
2022-10-07 11:39:14 +01:00
|
|
|
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;
|