* -------------------------------------------------------------------------
* 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 React, { useEffect, useMemo, useState } from 'react';
import { observer } from 'mobx-react';
import {
chartColors,
COLOR,
safeStr,
formatDecimal,
getLegendStyle,
useWatchDomResize,
getDefaultChartOptions,
} from '@insight/lib/utils';
import i18n from '@insight/lib/i18n';
import { cloneDeep } from 'lodash';
import type { Point, IRooflineChart } from './Index';
import * as echarts from 'echarts';
import { CollapsiblePanel } from '@insight/lib/components';
import { useTheme, type Theme } from '@emotion/react';
import { LimitHit } from '../../LimitSet';
import { useTranslation } from 'react-i18next';
const baseOption: any = {
textStyle: getDefaultChartOptions().textStyle,
title: {
left: 'center',
textStyle: {
color: COLOR.Grey20,
},
},
color: chartColors,
tooltip: {
confine: true,
axisPointer: {
type: 'cross',
},
trigger: 'item',
enterable: true,
},
legend: {
type: 'scroll',
orient: 'horizontal',
tooltip: {
show: true,
formatter: function () {
const div = document.createElement('div');
div.className = 'legend-tooltip';
div.append(i18n.t('chart:switchTooltip'));
return div;
},
},
formatter: (name: string) => {
if (name.startsWith('X(') || name.startsWith('Y(')) {
return name.slice(2, -1);
}
return name;
},
textStyle: {
color: COLOR.Grey20,
},
top: 30,
left: 'center',
itemGap: 16,
icon: 'circle',
},
grid: {
bottom: '30',
containLabel: true,
top: 100,
},
xAxis: {
type: 'log',
name: 'Ops/Byte',
nameTextStyle: {
color: COLOR.Grey20,
},
axisLabel: {
color: COLOR.Grey40,
},
},
yAxis: {
type: 'log',
name: 'TOps/s',
axisLabel: {
color: COLOR.Grey40,
},
nameTextStyle: {
color: COLOR.Grey20,
},
nameGap: 25,
axisPointer: {
type: 'shadow',
},
},
};
function setCustomLegendOption(theme: Theme): any {
return {
icon: 'roundRect',
top: 62,
left: 'center',
right: '12px',
orient: 'horizontal',
itemWidth: 16,
itemHeight: 12,
itemGap: 16,
itemStyle: { color: '#6DB9E8' },
textStyle: { color: theme.textColorTertiary },
formatter: (name: string): string => {
name = name.slice(2, -1);
if (name.length > 30) {
return `${name.slice(0, 28)}...`;
}
return name;
},
tooltip: {
show: true,
formatter: (params: any): string => `<div class="legend-tooltip">${safeStr(params.name).slice(2, -1) ?? ''}</div>`,
},
};
}
type Option = typeof baseOption;
interface GetActiveLegendItem {
selectedOfX: { selected?: {[key: string]: boolean}};
selectedOfMix: { selected?: {[key: string]: boolean}};
}
* 初始化时仅展示一个组类的折线和散点
* @param isInit
* @param xLegend
* @param mixLegend
*/
function getActiveLegendWhenInit(isInit: boolean, xLegend: Array<{ [key: string]: any}>, mixLegend: string[]): GetActiveLegendItem {
const selectedOfXName: { [key: string]: boolean } = {};
const selectedOfMixName: { [key: string]: boolean } = {};
if (isInit) {
let activeName = xLegend[0]?.name ?? '';
xLegend.forEach(item => {
selectedOfXName[item.name] = item.name === activeName;
});
activeName = activeName.slice(2, -1);
mixLegend.forEach(name => {
selectedOfMixName[name] = name.startsWith(activeName);
});
}
const selectedOfX = isInit ? { selected: selectedOfXName } : {};
const selectedOfMix = isInit ? { selected: selectedOfMixName } : {};
return { selectedOfX, selectedOfMix };
}
function wrapData(originData: IRooflineChart, theme: Theme, isInit: boolean): Option {
const series: any[] = [];
const xLegend: Array<{ [key: string]: any }> = [];
const yLegend: Array<{ [key: string]: any }> = [];
const mixLegend: string[] = [];
const transInfo = getRoofInfo(originData);
const bwNameList: string[] = [];
const option = cloneDeep(baseOption);
if (transInfo !== null) {
const { maxAxisX, minAxis } = transInfo;
let labelPoint: Point | undefined;
originData.rooflines.forEach((roofline, index) => {
const { bw, computility, bwName, point, ratio, computilityName } = roofline;
!bwNameList.includes(bwName) && bwNameList.push(bwName);
const allPositive = bw > 0 && computility > 0 && point?.[0] > 0 && point?.[1] > 0;
if (!allPositive) { return; }
const crossPoint: Point = bw > 1 ? [minAxis, bw * minAxis] : [minAxis / bw, minAxis];
const turningPoint: Point = [computility / bw, computility];
const rightPoint: Point = [maxAxisX, computility];
const rooflinePoints: Point[] = [crossPoint, turningPoint, rightPoint];
series.push(getPointSerie(bwName, [...point, bw, bwName, ratio, point, computilityName], bwNameList));
series.push(getRooflineSerie(bwName, rooflinePoints, bwNameList, computilityName));
mixLegend.push(`${bwName}(${computilityName})`);
!xLegend.find(item => item.name === `X(${bwName})`) && xLegend.push({ name: `X(${bwName})`, itemStyle: { color: getColorByBwName(bwName, bwNameList) } });
!yLegend.find(item => item.name === `Y(${computilityName})`) && yLegend.push({ name: `Y(${computilityName})` });
if (labelPoint === undefined) { labelPoint = rightPoint; }
});
if (yLegend.length === 1 && labelPoint !== undefined) {
series.push(getComputilityNameSerie(labelPoint, yLegend[0].name.slice(2, -1) ?? '', theme));
}
option.xAxis.min = minAxis;
option.xAxis.max = maxAxisX;
}
option.title.text = originData.title;
option.series = [...series, ...[...xLegend, ...yLegend].map(item => ({ ...getRooflineSerie(item.name, [], bwNameList, ''), show: false }))];
const { selectedOfX, selectedOfMix } = getActiveLegendWhenInit(isInit, xLegend, mixLegend);
option.legend = [
{ ...baseOption.legend, ...selectedOfMix, data: mixLegend, show: false },
{ ...baseOption.legend, ...selectedOfX, data: xLegend },
{ ...baseOption.legend, data: yLegend, ...setCustomLegendOption(theme), show: yLegend.length > 1 },
];
return getOptionStyle(option, theme);
}
function getColorByBwName(name: string, list: string[]): string {
const _name = name.startsWith('X(') || name.startsWith('Y(') ? name.slice(2, -1) : name;
const idx = list.findIndex(item => item === _name) ?? 0;
return chartColors[(idx % chartColors.length)];
}
function getPointSerie(bwName: string, data: Array<number | string | Point>, bwNameList: string[]): any {
const computilityName = data[data.length - 1];
return {
name: `${bwName}(${computilityName})`,
itemStyle: { color: getColorByBwName(bwName, bwNameList) },
data: [data],
type: 'scatter',
symbolSize: 16,
emphasis: { scale: 1.2 },
zlevel: 2,
tooltip: {
trigger: 'item',
formatter: getTooltipFormatter(),
},
};
}
function getRooflineSerie(bwName: string, rooflinePoints: Point[], bwNameList: string[], computilityName: string): any {
const compName = computilityName ? `(${computilityName})` : '';
const color = getColorByBwName(bwName, bwNameList);
return {
name: `${bwName}${compName}`,
lineStyle: { width: 2, color },
symbol: 'emptyCircle',
color,
data: rooflinePoints,
type: 'line',
emphasis: {
lineStyle: { width: 4 },
},
zlevel: 1,
tooltip: {
trigger: 'item',
formatter: (params: any) => {
const compName = params.seriesName.split(bwName)?.[1]?.slice(1, -1);
return `<span>${safeStr(compName ?? '')}</span>`;
},
},
};
}
function getComputilityNameSerie(labelPoint: Point, computilityNameLabel: string, theme: Theme): any {
return {
name: 'computilityName',
type: 'scatter',
symbolSize: 0,
data: [labelPoint],
emphasis: { disabled: true },
label: {
position: 'insideBottomRight',
distance: 5,
show: true,
formatter: safeStr(computilityNameLabel ?? ''),
padding: 10,
fontSize: 14,
color: theme.textColor,
fontWeight: 'bold',
},
tooltip: { show: false },
};
}
function getOptionStyle(option: Option, theme: Theme): Option {
option.title.textStyle.color = theme.textColorSecondary;
option.legend = option.legend.map((item: any) => ({ ...item, ...getLegendStyle(theme) }));
option.xAxis.nameTextStyle.color = theme.textColorTertiary;
option.yAxis.nameTextStyle.color = theme.textColorTertiary;
option.xAxis.splitLine = {
lineStyle: {
color: [theme.borderColorLight],
},
};
option.yAxis.splitLine = {
lineStyle: {
color: [theme.borderColorLight],
},
};
return option;
}
function getRoofInfo(data: IRooflineChart): {
maxAxisX: number;
minAxis: number;
} | null {
if (data.rooflines.length === 0) {
return null;
}
const allPoints = data.rooflines.reduce<Point[]>((pre, roofline) => {
const { point, bw, computility } = roofline;
const allPositive = point[0] > 0 && point[1] > 0 && bw > 0 && computility > 0;
if (allPositive) {
const turningPoint: Point = [computility / bw, computility];
pre.push(point, turningPoint);
}
return pre;
}, []);
if (allPoints.length === 0) {
return null;
}
let minX = Number.MAX_VALUE;
let minY = Number.MAX_VALUE;
let maxX = 0;
let maxY = 0;
allPoints.forEach(point => {
minX = Math.min(point[0], minX);
minY = Math.min(point[1], minY);
maxX = Math.max(point[0], maxX);
maxY = Math.max(point[1], maxY);
});
const maxAxisX = getDigit(maxX, 2);
const minAxis = getDigit(Math.min(minX, minY, 1), -1);
return {
maxAxisX,
minAxis,
};
}
export function getDigit(num: number, diff = 0): number {
if (num <= 0) {
return 0.1;
}
const decimalCount = Math.floor(Math.log10(num));
return Math.pow(10, decimalCount + diff);
}
function getTooltipFormatter(): (p: any) => string {
return (params: any) => {
const tips = getTipText(params);
return tips === '' ? '' : `<div style="max-height:400px;overflow-y:auto;">${tips}</div>`;
};
}
const getTipText = (params: any): any => {
if (params.data !== undefined && params.seriesType === 'scatter' && params.seriesName !== 'computilityName') {
const keepDecimalNum = 3;
const [, , bw, bwName, ratio, point, computilityName] = params.data;
return `<div>
<div>${params.marker}${safeStr(bwName)}(${computilityName})</div>
<div>${i18n.t('Bandwidth', { ns: 'details' })}: ${safeStr(formatDecimal(bw, keepDecimalNum))}TB/s</div>
<div>${i18n.t('Intensity', { ns: 'details' })}: ${safeStr(formatDecimal(point[0], keepDecimalNum))}Ops/Byte</div>
<div>${i18n.t('Performance', { ns: 'details' })}: ${safeStr(formatDecimal(point[1], keepDecimalNum))}TOps/s</div>
<div>${i18n.t('Performance Ratio', { ns: 'details' })}: ${safeStr(formatDecimal((100 * Number(ratio)), keepDecimalNum))}%</div>
</div>`;
} else {
return '';
}
};
function InitChart(data: IRooflineChart, chartDom: HTMLElement | null, theme: Theme, isInit: boolean, setIsInit: React.Dispatch<React.SetStateAction<boolean>>): void {
if (!chartDom) {
return;
}
const newChart = echarts.getInstanceByDom(chartDom)
? echarts.getInstanceByDom(chartDom)
: echarts.init(chartDom);
const chartOption = wrapData(data, theme, isInit);
newChart?.setOption(chartOption, { replaceMerge: ['series'] });
if (isInit) {
setTimeout(() => {
const xLegend = [...chartOption.legend[1].data];
for (let i = 1; i < xLegend.length; i++) {
newChart?.dispatchAction({ name: xLegend[i].name, type: 'legendUnSelect' });
}
});
setIsInit(false);
}
newChart?.on('legendselectchanged', (params: any) => {
const isBwName = params.name.startsWith('X(');
const status = params.selected[params.name] as boolean;
const nameOfLines: string[] = [];
const selectedList: string[] = [];
Object.keys(params.selected).forEach(name => {
const isStartX = name.startsWith('X(');
const isStartY = name.startsWith('Y(');
if (!isStartX && !isStartY) {
nameOfLines.push(name);
} else {
if (((isBwName && isStartY) || (!isBwName && isStartX)) && params.selected[name]) {
selectedList.push(name.slice(2, -1));
}
}
});
const _name = params.name.slice(2, -1);
const listOfChanged: string[] = selectedList.map(item => isBwName ? `${_name}(${item})` : `${item}(${_name})`);
const selected: { [key: string]: boolean } = {};
nameOfLines.forEach(name => {
selected[name] = listOfChanged.includes(name) ? status : params.selected[name];
});
const chartOption = newChart?.getOption() as any;
chartOption.legend[0].selected = selected;
newChart?.setOption(chartOption);
});
}
const MAX_ROOFLINE_NUM = 100;
const RooflineChart = observer(({ dataSource: orginDataSource }: { dataSource: IRooflineChart}): JSX.Element => {
const theme = useTheme();
const { t } = useTranslation('details');
const [width, ref] = useWatchDomResize<HTMLDivElement>('width');
const [limit, setLimit] = useState({ maxSize: MAX_ROOFLINE_NUM, overlimit: false, current: 0 });
const [isInit, setIsInit] = useState(true);
const size = orginDataSource?.rooflines?.length ?? 0;
const dataSource = useMemo(() => ({
...orginDataSource,
rooflines: orginDataSource.rooflines.slice(0, limit.maxSize),
}), [orginDataSource, limit.maxSize]);
useEffect(() => {
setLimit({ ...limit, overlimit: size > limit.maxSize, current: size });
}, [size]);
useEffect(() => {
InitChart(dataSource, ref.current, theme, isInit, setIsInit);
}, [dataSource, theme]);
useEffect(() => {
if (ref.current === null || width <= 0) {
return;
}
echarts.getInstanceByDom(ref.current)?.resize();
}, [width]);
return (
<div>
{limit.overlimit && <LimitHit maxSize={limit.maxSize} name={`${dataSource.title} ${t('Roofline Data')} (${limit.current})`} style={{ margin: '10px 0 10px 50px' }} />}
<div ref={ref} style={{ height: '500px', width: '100%' }}> </div>
</div>
);
});
export const RooflineChartGroup = ({ dataSource }: {
dataSource: IRooflineChart[];
}): JSX.Element => {
const theme = useTheme();
return <div>
{
dataSource.slice(0, 1).map(item => (<RooflineChart key={item.title} dataSource={item}/>))
}
{
dataSource.length > 1
? (
<CollapsiblePanel title={i18n.t('More')} collapsible defaultOpen={false}
headerStyle={{ color: theme.primaryColor, fontSize: '12px', cursor: 'pointer', paddingLeft: '12px' }}
contentStyle={{ padding: 0, width: 'calc( 100% + 50px)', marginLeft: '-50px' }}
style={{ margin: '0 0 20px 50px' }}
>
{dataSource.slice(1).map(item => (<RooflineChart key={item.title} dataSource={item}/>))}
</CollapsiblePanel>
)
: <></>
}
</div>;
};
export default RooflineChart;