Files
gpustack-ui/src/pages/_components/pie-chart/index.tsx
T
jialinandjialin 5321973ebe perf(charts): import chart components from @gpustack/core-ui/charts
Switch all echarts-based chart imports to the dedicated core-ui charts
entry so echarts stays out of the synchronous entry bundle and loads
only on chart routes. Requires @gpustack/core-ui with the ./charts export.
2026-07-01 20:54:26 +08:00

235 lines
6.5 KiB
TypeScript

import useCoolColors from '@/hooks/use-cool-colors';
import { Chart } from '@gpustack/core-ui/charts';
import { formatLargeNumber } from '@gpustack/core-ui/utils';
import { Empty, Spin, theme } from 'antd';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react';
export interface PieChartItem {
name: string;
value: number;
}
interface PieChartProps {
data: PieChartItem[];
height?: number | string;
width?: number | string;
loading?: boolean;
colorOffset?: number;
total?: number;
totalLabel?: string;
}
// Donut sits on the left; a vertical legend hugs it just to the right and
// scrolls when there are many entries.
const CENTER_X = '32%';
const round2 = (n: number) => Math.round((Number(n) || 0) * 100) / 100;
const PieChart: React.FC<PieChartProps> = ({
data,
height = 300,
width = '100%',
loading = false,
colorOffset = 0,
total,
totalLabel
}) => {
const { token } = theme.useToken();
const chartRef = useRef<{ chart: any } | null>(null);
const generateCoolColors = useCoolColors();
// ECharts reads the DOM width at init time; with a "100%" width it can pick
// up a stale/tiny value while layout is still resolving, briefly rendering
// the donut undersized before its internal (throttled) ResizeObserver
// corrects it — a visible flash. We measure the container via a callback ref
// (fires during commit, before paint) and feed ECharts an explicit pixel
// width so the first render is already correct. The ResizeObserver keeps it
// in sync afterwards.
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const [measuredWidth, setMeasuredWidth] = useState(0);
const measureRef = useCallback((node: HTMLDivElement | null) => {
resizeObserverRef.current?.disconnect();
if (!node) return;
setMeasuredWidth(node.clientWidth);
resizeObserverRef.current = new ResizeObserver(() => {
setMeasuredWidth(node.clientWidth);
});
resizeObserverRef.current.observe(node);
}, []);
useEffect(() => () => resizeObserverRef.current?.disconnect(), []);
const colors = useMemo(() => {
const generatedColors = generateCoolColors(data.length + colorOffset);
return generatedColors.slice(colorOffset);
}, [colorOffset, data.length, generateCoolColors]);
const options = useMemo(
() => ({
color: colors,
grid: {
left: 0,
right: 0,
top: 0,
bottom: 0,
containLabel: true
},
tooltip: {
trigger: 'item',
position: 'top',
// Keep the tooltip inside the chart box — the donut sits on the left, so
// a left-slice tooltip could otherwise spill out and get hidden behind
// the side menu.
confine: false,
backgroundColor: token.colorBgElevated,
borderColor: 'transparent',
formatter: (params: any) => {
return `<div class="tooltip-wrapper">
<span class="tooltip-item">
<span class="tooltip-item-name">
<span class="tooltip-item-dot" style="border-radius:50%;background-color:${params.color};"></span>
<span class="tooltip-item-title">${params.name}</span>:
</span>
<span class="tooltip-value">${formatLargeNumber(round2(params.value))} (${params.percent}%)</span>
</span>
</div>`;
}
},
legend: {
type: 'scroll',
orient: 'vertical',
left: '56%',
top: 'middle',
itemWidth: 8,
itemHeight: 8,
itemGap: 10,
// Long names are truncated in the legend; hovering shows the full name
// in a tooltip.
tooltip: { show: true },
textStyle: {
color: token.colorTextTertiary,
overflow: 'truncate',
width: 120
},
pageTextStyle: { color: token.colorTextTertiary },
pageIconSize: 10,
pageIconColor: token.colorTextTertiary,
pageIconInactiveColor: token.colorTextDisabled,
data: data.map((item) => item.name),
show: data.length > 0
},
series: [
{
type: 'pie',
radius: ['54%', '78%'],
center: [CENTER_X, '50%'],
avoidLabelOverlap: true,
// No on-arc label — the percentage lives in the tooltip instead, so
// hovering shows value + percent in one place.
label: { show: false },
emphasis: {
label: {
show: false
},
scale: true
},
labelLine: { show: false },
data
}
]
}),
[colors, data, token]
);
if (!data.length) {
return (
<div
style={{
width,
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{loading ? (
<Spin size="middle" />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
);
}
const displayTotal = total ?? data.reduce((sum, item) => sum + item.value, 0);
return (
<div ref={measureRef} style={{ width, height, position: 'relative' }}>
<Chart
ref={chartRef as any}
options={options as any}
height={height}
width={measuredWidth || width}
/>
<div
style={{
position: 'absolute',
left: CENTER_X,
top: '50%',
transform: 'translate(-50%, -50%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
textAlign: 'center'
}}
>
<span
style={{
fontSize: 20,
fontWeight: 600,
color: token.colorText,
lineHeight: 1.2
}}
>
{formatLargeNumber(displayTotal)}
</span>
{totalLabel && (
<span
style={{
fontSize: 12,
color: token.colorTextTertiary,
marginTop: 4
}}
>
{totalLabel}
</span>
)}
</div>
{loading && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--ant-color-bg-container)',
opacity: 0.6,
pointerEvents: 'none'
}}
>
<Spin size="middle" />
</div>
)}
</div>
);
};
export default PieChart;