* -------------------------------------------------------------------------
* This file is part of the MindStudio project.
* Copyright (c) 2025 Huawei Technologies Co.,Ltd.
*
* MindStudio is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
*
* http://license.coscl.org.cn/MulanPSL2
*
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
* -------------------------------------------------------------------------
*/
import type { Theme } from '@emotion/react';
import { useTheme } from '@emotion/react';
import * as d3 from 'd3';
import { runInAction } from 'mobx';
import { observer } from 'mobx-react';
import React, { useEffect, useMemo, useRef } from 'react';
import type { ChartProps, ChartReaction, Scale, StackStatusData, TextConfig } from '../../entity/chart';
import { UnitHeight } from '../../entity/insight';
import type { Session } from '../../entity/session';
import { Canvas, CanvasContainer, zipStatusData } from './common';
import { useBatchedRender, useClick, useData, useHoverPos, useRangeAndDomain } from './hooks';
import { TooltipComponent, type TooltipProps } from './TooltipComp';
import type { ThreadMetaData } from '../../entity/data';
import { Spin } from 'antd';
type StackStatusChartProps = ChartProps<'stackStatus'>;
type OverflowType = 'hidden' | 'ellipsis';
type DrawTextType = Array<StackStatusData & { width: number }>;
const FONT_SIZE = 12;
const DFT_PADDING = 8;
const CHEVRON_WIDTH = 12;
const fontMetricsCache = new Map();
const getYOffset = (ctx: CanvasRenderingContext2D): number => {
const font = ctx.font;
if (fontMetricsCache.has(font)) {
return fontMetricsCache.get(font);
}
const m = ctx.measureText('M');
const ascent = m.actualBoundingBoxAscent;
const descent = m.actualBoundingBoxDescent;
const offset = (ascent - descent) / 2;
fontMetricsCache.set(font, offset);
return offset;
};
const getMaxText = (text: string, maxWidth: number, ctx: CanvasRenderingContext2D, overflow: OverflowType): string => {
if (ctx.measureText(text).width <= maxWidth) { return text; }
if (overflow === 'hidden') { return ''; }
let left = 0;
let right = text.length;
let mid = 0;
while (left < right) {
mid = Math.floor((left + right) / 2);
if (ctx.measureText(`${text.slice(0, mid)}...`).width > maxWidth) {
right = mid;
} else {
if (left === mid) { break; }
left = mid;
}
}
return `${text.slice(0, mid)}...`;
};
* 画人字形型箭头
* @param ctx
* @param x 箭头顶部 x 坐标
* @param y 箭头顶部 y 坐标
* @param h 箭头高
*/
export const drawChevron = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
h: number,
): void => {
const HALF_CHEVRON_WIDTH = CHEVRON_WIDTH / 2;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + HALF_CHEVRON_WIDTH, y + h);
ctx.lineTo(x, y + h - HALF_CHEVRON_WIDTH);
ctx.lineTo(x - HALF_CHEVRON_WIDTH, y + h);
ctx.lineTo(x, y);
ctx.closePath();
ctx.fill();
};
const drawRect = (ctx: CanvasRenderingContext2D, dataObj: { data: StackStatusData; textToDraw: DrawTextType },
config: { height: number; right: number; xScale: Scale; yScale: Scale; overflow: OverflowType; minTextWidth: number; order?: number },
isSimulation: boolean): void => {
const { data, textToDraw } = dataObj;
const { height, right, xScale, yScale, overflow, minTextWidth, order } = config;
let startTime = xScale(data.startTime);
let width = Math.max(1, xScale(data.duration < 0 ? right : (data.startTime + data.duration)) - startTime);
const minWidth = overflow === 'ellipsis' ? minTextWidth : ctx.measureText(data.type).width + DFT_PADDING;
if (width >= minWidth) {
textToDraw.push({ ...data, width });
}
if (order !== undefined && data.color instanceof Array) {
width = order === 0 ? Math.abs(xScale(data.color[0][0]) - startTime) + 0.5 : Math.abs(xScale(data.color[1][0]) - xScale(data.color[0][0]));
startTime = order === 0 ? startTime : xScale(data.color[0][0]) + 0.5;
}
if (isSimulation && height < UnitHeight.STANDARD - 1) {
ctx.fillRect(startTime, 1, width, UnitHeight.COLL - 2);
return;
}
if (data.duration === 0) {
const startHeight = yScale(data.depth) + 1;
drawChevron(ctx, startTime, startHeight, height - 2);
return;
}
ctx.fillRect(startTime, yScale(data.depth) + 1, width, height - 2);
};
function dealDataColor(theme: Theme, dataColor: Map<keyof Theme['colorPalette'], StackStatusData[]>,
dataMultiColor: StackStatusData[], dataList: StackStatusData[][]): void {
Object.keys(theme.colorPalette).forEach(key => {
dataColor.set(key as keyof Theme['colorPalette'], []);
});
dataList.forEach(it => it.forEach(data => {
if (data.color instanceof Array) {
if (data.color.length === 2) {
dataMultiColor.push(data);
}
} else {
dataColor.get(data.color)?.push(data);
}
}));
}
interface DrawParams {
ctx: CanvasRenderingContext2D | null;
dataList: StackStatusData[][];
xScale: Scale;
yScale: Scale;
theme: Theme;
right: number;
isSimulation: boolean;
textConfig?: TextConfig;
}
const draw = ({ ctx, dataList, xScale, yScale, theme, right, isSimulation, textConfig }: DrawParams): void => {
if (!ctx) {
return;
}
const { overflow, textAlign } = textConfig ?? { overflow: 'ellipsis', textAlign: 'start' };
ctx.font = `${FONT_SIZE}px -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif`;
ctx.textAlign = textAlign;
ctx.textBaseline = 'top';
const minTextWidth = ctx.measureText('...').width + DFT_PADDING;
const dataColor = new Map<keyof Theme['colorPalette'], StackStatusData[]>();
const dataMultiColor: StackStatusData[] = [];
dealDataColor(theme, dataColor, dataMultiColor, dataList);
const height = yScale(1) - yScale(0);
const textToDraw: DrawTextType = [];
const func = (arr: StackStatusData[], key: keyof Theme['colorPalette']): void => {
ctx.fillStyle = theme.colorPalette[key];
arr.forEach(data => {
drawRect(ctx, { data, textToDraw }, { height, right, xScale, yScale, overflow, minTextWidth }, isSimulation);
});
};
dataColor.forEach(func);
if (dataMultiColor.length > 0) {
for (let i = 0; i < 2; i++) {
ctx.fillStyle = theme.colorPalette[dataMultiColor[0].color[i][1] as keyof Theme['colorPalette']];
dataMultiColor.forEach(data => {
drawRect(ctx, { data, textToDraw }, { height, right, xScale, yScale, overflow, minTextWidth, order: i }, isSimulation);
});
}
}
ctx.fillStyle = '#FFFFFFE6';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
if (height > UnitHeight.STANDARD - 1) {
textToDraw.forEach(data => {
const text = getMaxText(data.type, data.width - DFT_PADDING, ctx, overflow);
const yOffset = getYOffset(ctx);
const x = xScale(data.startTime) + data.width / 2;
const y = yScale(data.depth) + height / 2 + yOffset;
ctx.fillText(text, x, y);
});
}
};
const findDataByXY = (mousePos: { x: number; y: number } | undefined, dataList: StackStatusData[][],
rangeAndDomain: Array<[number, number]>, depthHeight: number, endTime: number): StackStatusData | undefined => {
if (mousePos === undefined || dataList.length === 0 || rangeAndDomain.length < 2) {
return undefined;
}
const mouseTime = d3.scaleLinear().range(rangeAndDomain[1]).domain(rangeAndDomain[0]).clamp(false)(mousePos.x);
const range = d3.scaleLinear().range(rangeAndDomain[1]).domain(rangeAndDomain[0]).clamp(false)(CHEVRON_WIDTH / 2);
const depth = Math.floor(mousePos.y / depthHeight);
const data = dataList[depth];
if (data === undefined || data.length === 0) {
return undefined;
}
let rangeTime = data[0].duration === 0 ? range - rangeAndDomain[1][0] : 0;
if (data[0].startTime - rangeTime > mouseTime || endTime + rangeTime < mouseTime ||
(data[data.length - 1].startTime + data[data.length - 1].duration + rangeTime < mouseTime)) {
return undefined;
}
let lo = 0;
let hi = data.length;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
const elem = data[mid];
rangeTime = elem.duration === 0 ? range - rangeAndDomain[1][0] : 0;
if (mouseTime < elem.startTime - rangeTime) {
hi = mid;
} else if ((elem.duration >= 0 && mouseTime > elem.startTime + elem.duration + rangeTime)) {
lo = mid + 1;
} else {
return elem;
}
}
return undefined;
};
const findDataByXXRange = ([downX, upX]: number[], dataList: StackStatusData[][],
rangeAndDomain: Array<[number, number]>): StackStatusData[] | undefined => {
const isUndefinedOrEmpty = downX === undefined || upX === undefined || dataList.length === 0 || rangeAndDomain.length === 0;
if (isUndefinedOrEmpty) {
return undefined;
}
const sX = Math.min(downX, upX);
const eX = Math.max(downX, upX);
const mouseSTime = d3.scaleLinear().range(rangeAndDomain[1]).domain(rangeAndDomain[0]).clamp(false)(sX);
const mouseETime = d3.scaleLinear().range(rangeAndDomain[1]).domain(rangeAndDomain[0]).clamp(false)(eX);
const result = [] as StackStatusData[];
dataList.forEach((data) => {
data.forEach((elem) => {
if (elem.startTime < mouseETime && elem.startTime + elem.duration > mouseSTime) {
result.push(elem);
}
});
});
return result.length > 0 ? result : undefined;
};
interface MouseUpFuncParams {
e: MouseEvent;
datasState: StackStatusData[][];
rangeAndDomain: Array<[number, number]>;
rowHeight: UnitHeight; session: Session;
metadata: unknown;
onClick?: ChartReaction<'stackStatus'>;
}
const mouseUpFunc = ({ e, datasState, rangeAndDomain, rowHeight, session, metadata, onClick }: MouseUpFuncParams): void => {
const clickedData = findDataByXY({ x: e.offsetX, y: e.offsetY }, datasState, rangeAndDomain, rowHeight, session.endTimeAll ?? 0);
if (clickedData !== undefined) {
clickedData.showSelectedData = true;
}
runInAction(() => {
session.selectedData = clickedData
? {
...clickedData,
threadId: (metadata as ThreadMetaData).threadId ?? clickedData.threadId ?? '',
processId: (metadata as ThreadMetaData).processId ?? '',
timestamp: clickedData.originalStartTime as number,
metaType: (metadata as ThreadMetaData).metaType ?? '',
}
: undefined;
if (!session.selectedData) {
session.drawLineMode = 'all';
}
onClick?.(clickedData, session, metadata);
session.selectedRangeData = undefined;
});
};
const mouseMoveUpFunc = ([downX, upX]: number[], datasState: StackStatusData[][], rangeAndDomain: Array<[number, number]>,
session: Session, metadata: unknown): void => {
const selectedRangeData = findDataByXXRange([downX, upX], datasState, rangeAndDomain);
const threadMetaData = metadata as ThreadMetaData;
runInAction(() => {
session.selectedRangeData = selectedRangeData?.map(item => ({
...item,
threadId: threadMetaData.threadId ?? item.threadId,
processId: threadMetaData.processId ?? '',
cardId: threadMetaData.cardId,
dbPath: threadMetaData.dbPath,
metaType: threadMetaData.metaType ?? '',
}));
});
};
export const StackStatusChart = observer(({ // 绘制 slice 的画布
session, unit, margin, mapFunc, metadata, renderTooltip, height, onHover, onClick, decorator,
rowHeight, width, textConfig, isNeedClamp, isCollapse, maxDepth,
}: StackStatusChartProps) => {
const theme = useTheme();
const canvasContainer = useRef<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const { action: drawExt = (): void => { }, triggers = [] } = decorator?.(session, metadata) ?? {};
const datasState = useData({
session,
mapFunc,
unit,
metadata,
width,
processor: (data, processedWidth, start, end) => data.map(row => zipStatusData(row, processedWidth, start, end)),
});
const rangeAndDomain = useRangeAndDomain(session, width, margin); const mousePos = useHoverPos(canvasContainer);
const hoveredData = useMemo(
() => findDataByXY(mousePos, datasState, rangeAndDomain, rowHeight, session.endTimeAll ?? 0),
[mousePos, datasState, rangeAndDomain],
);
const handleMouseUp = (e: MouseEvent): void => {
mouseUpFunc({
e,
datasState,
rangeAndDomain,
rowHeight,
session,
metadata,
onClick,
});
};
const handleMouseMoveUp = ([downX, upX]: number[]): void => { mouseMoveUpFunc([downX, upX], datasState, rangeAndDomain, session, metadata); };
useEffect(() => onHover?.(hoveredData, session, metadata), [hoveredData, metadata]);
useClick({ canvasContainer, dataState: datasState, rangeAndDomain, session, metadata, handleMouseUp, handleMouseMoveUp });
const yScale = isCollapse ? d3.scaleLinear().range([0, height]).domain([0, maxDepth as number]) : (depth: number): number => depth * rowHeight;
useBatchedRender(() => {
const noRender = canvasContainer.current === null || canvas.current === null || rangeAndDomain.length === 0 ||
canvas.current.width === 0 || canvas.current.height === 0;
if (noRender) {
return;
}
const ctx = canvas.current.getContext('2d');
const xScale = d3.scaleLinear().range(rangeAndDomain[0]).domain(rangeAndDomain[1]).clamp(isNeedClamp ?? true);
ctx?.resetTransform();
ctx?.scale(devicePixelRatio, devicePixelRatio);
ctx?.clearRect(0, 0, width, height);
draw({ ctx, dataList: datasState, xScale, yScale, theme, right: session.endTimeAll ?? 0, isSimulation: session.isSimulation, textConfig });
drawExt({
context: ctx,
draw: (data, xScaleExt, yScaleExt) => draw({
ctx,
dataList: data,
xScale: xScaleExt,
yScale: yScaleExt,
theme,
right: session.endTimeAll ?? 0,
isSimulation: session.isSimulation,
textConfig,
}),
findAll: (condition) => datasState.map(it => it.filter(condition)),
}, xScale, yScale, theme);
}, [datasState, rangeAndDomain, ...triggers, theme, isCollapse]);
const tooltipProp: TooltipProps<StackStatusData, StackStatusData[][]> = {
data: hoveredData,
mouseX: mousePos?.x ?? null,
session,
dataset: datasState,
calcHeight: (data) => (data.depth * rowHeight) + (rowHeight / 2),
dom: canvasContainer,
renderContent: (data) => renderTooltip?.(data),
};
return <CanvasContainer ref={canvasContainer} className={'canvasContainer'} width={width} height={height} style={{ pointerEvents: `${isCollapse ? 'none' : 'auto'}` }}>
{ !isCollapse && <TooltipComponent {...tooltipProp} /> }
<Spin spinning={unit.isTraceLoading} delay={2500} size="small">
<Canvas className={'drawCanvas'} ref={canvas} width={width * devicePixelRatio} height={height * devicePixelRatio}/>
</Spin>
</CanvasContainer>;
});