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.
350 lines
10 KiB
TypeScript
350 lines
10 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 _ from 'lodash';
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState
|
|
} from 'react';
|
|
|
|
export interface BarSeriesItem {
|
|
name: string;
|
|
data: any[];
|
|
color?: string;
|
|
stack?: string | false;
|
|
}
|
|
|
|
export interface BarChartProps {
|
|
seriesData: BarSeriesItem[];
|
|
xAxisData: string[];
|
|
height: number | string;
|
|
width?: number | string;
|
|
loading?: boolean;
|
|
legendData?: { name: string; icon?: string }[];
|
|
labelFormatter?: (val: any, index?: number) => string;
|
|
tooltipValueFormatter?: (val: any) => string;
|
|
title?: string;
|
|
grid?: {
|
|
left?: number | string;
|
|
right?: number | string;
|
|
top?: number | string;
|
|
bottom?: number | string;
|
|
containLabel?: boolean;
|
|
};
|
|
legendIsolate?: boolean;
|
|
}
|
|
|
|
const BarChart: React.FC<BarChartProps> = (props) => {
|
|
const {
|
|
seriesData,
|
|
xAxisData,
|
|
height,
|
|
width,
|
|
legendData,
|
|
loading,
|
|
labelFormatter,
|
|
tooltipValueFormatter,
|
|
title,
|
|
grid,
|
|
legendIsolate
|
|
} = props;
|
|
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 the flex child's layout is still resolving,
|
|
// rendering every bar squeezed at the left edge until its internal (throttled)
|
|
// ResizeObserver corrects it ~100ms later — a visible blue-sliver flash. We
|
|
// measure the container ourselves via a callback ref (which fires during
|
|
// commit, before paint) and feed ECharts an explicit pixel width, so the very
|
|
// first render is already correct. No gating, so the chart mounts with no
|
|
// extra delay; the ResizeObserver keeps the width 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 dynamicColors = useMemo(
|
|
() => generateCoolColors(seriesData.length),
|
|
[seriesData.length, generateCoolColors]
|
|
);
|
|
|
|
const options = useMemo(() => {
|
|
// Group series by stack name to compute top-of-stack per date
|
|
const stackGroups = new Map<string, BarSeriesItem[]>();
|
|
seriesData.forEach((s) => {
|
|
if (s.stack === false || !s.stack) return;
|
|
const list = stackGroups.get(s.stack) ?? [];
|
|
list.push(s);
|
|
stackGroups.set(s.stack, list);
|
|
});
|
|
|
|
// topMap[`${stack}::${dateIdx}`] = index within stackGroups list of the
|
|
// topmost visible (value > 0) series at that date
|
|
const topMap = new Map<string, number>();
|
|
stackGroups.forEach((stackSeries, stackKey) => {
|
|
const dateCount = Math.max(...stackSeries.map((s) => s.data.length), 0);
|
|
for (let dateIdx = 0; dateIdx < dateCount; dateIdx++) {
|
|
for (let i = stackSeries.length - 1; i >= 0; i--) {
|
|
const dp: any = stackSeries[i].data[dateIdx];
|
|
const value = dp && typeof dp === 'object' ? dp.value : dp;
|
|
if ((value ?? 0) > 0) {
|
|
topMap.set(`${stackKey}::${dateIdx}`, i);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const series = _.map(seriesData, (item: BarSeriesItem, index: number) => {
|
|
const { stack, color, data, ...rest } = item;
|
|
const resolvedColor = color || dynamicColors[index];
|
|
const stackKey = stack === false || !stack ? null : (stack as string);
|
|
const stackList = stackKey ? stackGroups.get(stackKey) : null;
|
|
const indexInStack = stackList ? stackList.indexOf(item) : -1;
|
|
|
|
const processedData =
|
|
stackKey && stackList
|
|
? data.map((dp: any, dateIdx: number) => {
|
|
if (dp == null) return dp;
|
|
const isTop =
|
|
topMap.get(`${stackKey}::${dateIdx}`) === indexInStack;
|
|
const base = typeof dp === 'object' ? dp : { value: dp };
|
|
return {
|
|
...base,
|
|
itemStyle: {
|
|
borderRadius: isTop ? [2, 2, 0, 0] : 0
|
|
}
|
|
};
|
|
})
|
|
: data;
|
|
|
|
return {
|
|
...rest,
|
|
data: processedData,
|
|
type: 'bar',
|
|
barMaxWidth: 20,
|
|
// Keep a small floor only — a large barMinWidth would force wide bars
|
|
// when there are many categories (e.g. hourly buckets), squeezing out
|
|
// the category gap so bars look fused. A low floor lets barCategoryGap
|
|
// win, so even dense hourly views keep visible gaps.
|
|
barMinWidth: 2,
|
|
barGap: '30%',
|
|
barCategoryGap: '50%',
|
|
...(stack === false || stack === undefined ? {} : { stack }),
|
|
itemStyle: {
|
|
color: resolvedColor,
|
|
borderRadius: [2, 2, 0, 0]
|
|
}
|
|
};
|
|
});
|
|
|
|
return {
|
|
title: {
|
|
show: true,
|
|
left: 'center',
|
|
textStyle: { fontSize: 12, color: token.colorText },
|
|
text: title || ''
|
|
},
|
|
grid: {
|
|
left: 0,
|
|
right: 0,
|
|
top: title ? 30 : 10,
|
|
bottom: 8,
|
|
containLabel: true,
|
|
...grid
|
|
},
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
backgroundColor: token.colorBgElevated,
|
|
borderColor: 'transparent',
|
|
formatter: (params: any) => {
|
|
let result = `<span class="tooltip-x-name">${params[0].axisValue}</span>`;
|
|
let visibleItemCount = 0;
|
|
|
|
params.forEach((item: any) => {
|
|
const raw = item.data?.value ?? item.value;
|
|
const value = tooltipValueFormatter
|
|
? tooltipValueFormatter(raw)
|
|
: raw;
|
|
|
|
if (value === null || value === undefined) return;
|
|
|
|
visibleItemCount += 1;
|
|
const stackLabel = item.data?.stackLabel;
|
|
const baseName = item.data?.tooltipName ?? item.seriesName;
|
|
const displayName = stackLabel
|
|
? `${baseName} (${stackLabel})`
|
|
: baseName;
|
|
result += `<span class="tooltip-item">
|
|
<span class="tooltip-item-name">
|
|
<span class="tooltip-item-dot" style="border-radius:2px;background-color:${item.color};"></span>
|
|
<span class="tooltip-item-title">${displayName}</span>:
|
|
</span>
|
|
<span class="tooltip-value">${value}</span>
|
|
</span>`;
|
|
});
|
|
|
|
const wrapperClassName =
|
|
visibleItemCount >= 12
|
|
? 'tooltip-wrapper tooltip-grid'
|
|
: 'tooltip-wrapper';
|
|
return `<div class="${wrapperClassName}">${result}</div>`;
|
|
}
|
|
},
|
|
legend: {
|
|
type: 'scroll',
|
|
itemWidth: 8,
|
|
itemHeight: 8,
|
|
itemGap: 12,
|
|
textStyle: { color: token.colorTextTertiary },
|
|
pageTextStyle: { color: token.colorTextTertiary },
|
|
pageIconColor: token.colorTextTertiary,
|
|
pageIconInactiveColor: token.colorTextDisabled,
|
|
bottom: 0,
|
|
data: legendData,
|
|
show: !!legendData?.length
|
|
},
|
|
xAxis: {
|
|
type: 'category',
|
|
axisTick: {
|
|
show: true,
|
|
lineStyle: { color: token.colorSplit }
|
|
},
|
|
axisLabel: {
|
|
color: token.colorTextTertiary,
|
|
fontSize: 12,
|
|
formatter: labelFormatter
|
|
},
|
|
axisLine: { show: false },
|
|
data: xAxisData
|
|
},
|
|
yAxis: {
|
|
type: 'value',
|
|
nameTextStyle: { padding: [0, 0, 0, -20] },
|
|
splitLine: {
|
|
show: true,
|
|
lineStyle: { type: 'dashed', color: token.colorBorder }
|
|
},
|
|
axisLabel: {
|
|
color: token.colorTextTertiary,
|
|
fontSize: 12,
|
|
formatter: formatLargeNumber
|
|
},
|
|
axisTick: { show: false }
|
|
},
|
|
animation: false,
|
|
series
|
|
};
|
|
}, [
|
|
seriesData,
|
|
xAxisData,
|
|
dynamicColors,
|
|
labelFormatter,
|
|
tooltipValueFormatter,
|
|
legendData,
|
|
title,
|
|
token
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!legendIsolate) return;
|
|
const chart = chartRef.current?.chart;
|
|
if (!chart) return;
|
|
|
|
const handler = (params: any) => {
|
|
const clicked = params.name;
|
|
const allNames = Object.keys(params.selected || {});
|
|
if (!allNames.length) return;
|
|
|
|
const isClickedSelected = !!params.selected[clicked];
|
|
const allOthersHidden = allNames
|
|
.filter((n) => n !== clicked)
|
|
.every((n) => !params.selected[n]);
|
|
|
|
let newSelected: Record<string, boolean>;
|
|
if (!isClickedSelected && allOthersHidden) {
|
|
// clicking the only-visible one → restore all
|
|
newSelected = Object.fromEntries(allNames.map((n) => [n, true]));
|
|
} else {
|
|
// isolate clicked
|
|
newSelected = Object.fromEntries(
|
|
allNames.map((n) => [n, n === clicked])
|
|
);
|
|
}
|
|
|
|
chart.setOption({ legend: { selected: newSelected } });
|
|
};
|
|
|
|
chart.on('legendselectchanged', handler);
|
|
return () => {
|
|
chart.off('legendselectchanged', handler);
|
|
};
|
|
}, [legendIsolate, seriesData, legendData]);
|
|
|
|
if (!seriesData.length) {
|
|
return (
|
|
<div
|
|
style={{
|
|
width: width || '100%',
|
|
height,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
}}
|
|
>
|
|
{loading ? (
|
|
<Spin size="middle" />
|
|
) : (
|
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={measureRef}
|
|
style={{ width: width || '100%', height, position: 'relative' }}
|
|
>
|
|
<Chart
|
|
ref={chartRef as any}
|
|
options={options as any}
|
|
height={height}
|
|
width={measuredWidth || width || '100%'}
|
|
/>
|
|
{loading && (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 20,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
background: 'var(--ant-color-bg-container)',
|
|
opacity: 0.6,
|
|
pointerEvents: 'none'
|
|
}}
|
|
>
|
|
<Spin size="middle" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default BarChart;
|