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 = (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(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(); 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(); 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 = `${params[0].axisValue}`; 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 += ` ${displayName}: ${value} `; }); const wrapperClassName = visibleItemCount >= 12 ? 'tooltip-wrapper tooltip-grid' : 'tooltip-wrapper'; return `
${result}
`; } }, 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; 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 (
{loading ? ( ) : ( )}
); } return (
{loading && (
)}
); }; export default BarChart;