feat(chart): 新增饼图组件并优化图表展示效果

- 新增 PieChart 组件用于展示饼图
- 优化 BarChart 和 LineChart 的数据处理和展示逻辑
- 改进 TableChart 的数据格式化和自定义渲染功能
- 修复 ChatContentContainer 的滚动逻辑,实现消息自动滚动到最新
main
Tuzki 3 months ago
parent e46fe71a5a
commit 83caed4c26
  1. 48
      web/components/chart/bar-chart.tsx
  2. 3
      web/components/chart/index.tsx
  3. 25
      web/components/chart/line-chart.tsx
  4. 104
      web/components/chart/pie-chart.tsx
  5. 95
      web/components/chart/table-chart.tsx
  6. 22
      web/new-components/chat/ChatContentContainer.tsx

@ -1,11 +1,33 @@
import { ChatContext } from '@/app/chat-context';
import { ChartData } from '@/types/chat';
import { Chart } from '@berryv/g2-react';
import { useContext } from 'react';
import { useContext ,useMemo} from 'react';
export default function BarChart({ chart }: { key: string; chart: ChartData }) {
const { mode } = useContext(ChatContext);
// Process data to ensure numeric values for proper y-axis ordering
const processedChart = useMemo(() => {
const processedValues = chart.values
.map(item => ({
...item,
value: typeof item.value === 'string' ? parseFloat(item.value) || 0 : item.value,
}))
// Sort by value in descending order for better visualization
.sort((a, b) => b.value - a.value);
return {
...chart,
values: processedValues,
};
}, [chart]);
// Smart number formatter: show integers as integers, decimals with 2 decimal places
const formatNumber = (value: any) => {
const num = Number(value);
return Number.isInteger(num) ? num.toString() : num.toFixed(2);
};
return (
<div className='flex-1 min-w-0 p-4 bg-white dark:bg-theme-dark-container rounded'>
<div className='h-full'>
@ -18,12 +40,32 @@ export default function BarChart({ chart }: { key: string; chart: ChartData }) {
autoFit: true,
theme: mode,
type: 'interval',
data: chart.values,
data: processedChart.values,
encode: { x: 'name', y: 'value', color: 'type' },
axis: {
x: {
labelAutoRotate: false,
title: false,
},
y: {
labelFormatter: formatNumber,
title: false,
},
},
tooltip: {
items: [
{
field: 'name',
name: '名称',
},
{
field: 'value',
name: '数值',
valueFormatter: formatNumber,
},
],
},
scale: {
value: { type: 'linear' },
},
}}
/>

@ -3,6 +3,7 @@ import { Card, CardContent, Typography } from '@mui/joy';
import { useMemo } from 'react';
import BarChart from './bar-chart';
import LineChart from './line-chart';
import PieChart from './pie-chart';
import TableChart from './table-chart';
type Props = {
@ -68,6 +69,8 @@ function Chart({ chartsData }: Props) {
return <BarChart key={chart.chart_uid} chart={chart} />;
} else if (chart.chart_type === 'Table' || chart.type === 'TableChartData') {
return <TableChart key={chart.chart_uid} chart={chart} />;
}else if (chart.chart_type === 'PieChart' || chart.type === 'PieChart') {
return <PieChart key={chart.chart_uid} chart={chart} />;
}
})}
</div>

@ -1,11 +1,24 @@
import { ChatContext } from '@/app/chat-context';
import { ChartData } from '@/types/chat';
import { Chart } from '@berryv/g2-react';
import { useContext } from 'react';
import { useContext,useMemo } from 'react';
export default function LineChart({ chart }: { chart: ChartData }) {
const { mode } = useContext(ChatContext);
// Process data to ensure numeric values for proper y-axis ordering
const processedChart = useMemo(() => {
const processedValues = chart.values.map(item => ({
...item,
value: typeof item.value === 'string' ? parseFloat(item.value) || 0 : item.value,
}));
return {
...chart,
values: processedValues,
};
}, [chart]);
return (
<div className='flex-1 min-w-0 p-4 bg-white dark:bg-theme-dark-container rounded'>
<div className='h-full'>
@ -18,7 +31,7 @@ export default function LineChart({ chart }: { chart: ChartData }) {
autoFit: true,
theme: mode,
type: 'view',
data: chart.values,
data: processedChart.values,
children: [
{
type: 'line',
@ -45,8 +58,14 @@ export default function LineChart({ chart }: { chart: ChartData }) {
],
axis: {
x: {
labelAutoRotate: false,
title: false,
},
y: {
title: false,
},
},
scale: {
value: { type: 'linear' },
},
}}
/>

@ -0,0 +1,104 @@
import { ChatContext } from '@/app/chat-context';
import { ChartData } from '@/types/chat';
import { Chart } from '@berryv/g2-react';
import { useContext, useMemo } from 'react';
export default function PieChart({ chart }: { key: string; chart: ChartData }) {
const { mode } = useContext(ChatContext);
// Transform raw data into pie chart format
const pieData = useMemo(() => {
if (!chart.values || !Array.isArray(chart.values)) {
return [];
}
return chart.values.map(item => ({
name: item.name,
value: Number(item.value) || 0,
}));
}, [chart.values]);
if (!pieData.length) {
return null;
}
// Calculate total for percentage
const total = pieData.reduce((sum, item) => sum + item.value, 0);
return (
<div className='flex-1 min-w-[300px] p-4 bg-white dark:bg-theme-dark-container rounded'>
<div className='h-full'>
<div className='mb-2'>{chart.chart_name}</div>
<div className='opacity-80 text-sm mb-2'>{chart.chart_desc}</div>
<div className='h-[300px]'>
<Chart
style={{ height: '100%' }}
options={{
autoFit: true,
data: pieData,
theme: mode,
animate: {
enter: {
type: 'waveIn',
duration: 500,
},
},
children: [
{
type: 'interval',
encode: {
y: 'value',
color: 'name',
},
transform: [{ type: 'stackY' }],
coordinate: {
type: 'theta',
outerRadius: 0.8,
},
style: {
lineWidth: 1,
stroke: '#fff',
},
state: {
active: {
style: {
lineWidth: 2,
stroke: '#fff',
fillOpacity: 0.9,
},
},
},
interaction: {
elementHighlightByColor: true,
},
},
],
legend: {
color: {
position: 'right',
title: false,
itemName: {
style: {
fill: mode === 'dark' ? '#fff' : '#333',
},
},
itemValue: {
formatter: (value: number) => {
const percentage = ((value / total) * 100).toFixed(1);
return `${percentage}%`;
},
},
},
},
tooltip: {
format: {
value: (v: number) => `${v}`,
},
},
}}
/>
</div>
</div>
</div>
);
}

@ -1,27 +1,102 @@
import { ChartData } from '@/types/chat';
import { Table } from '@mui/joy';
import { groupBy } from 'lodash';
import { useMemo } from 'react';
export default function TableChart({ chart }: { key: string; chart: ChartData }) {
const data = groupBy(chart.values, 'type');
interface TableChartProps {
chart: ChartData;
columnNameMap?: Record<string, string>;
renderCell?: (value: any, row: any, col: string) => React.ReactNode;
}
export default function TableChart({ chart, columnNameMap, renderCell }: TableChartProps) {
// Process table data
const { columns, dataSource } = useMemo(() => {
if (!chart.values || chart.values.length === 0) {
return { columns: [], dataSource: [] };
}
const firstRow = chart.values[0];
// Handle type-value structure
if ('type' in firstRow && 'value' in firstRow && 'name' in firstRow) {
// Group by name and transform type-value pairs into columns
const mergedData = new Map();
chart.values.forEach(item => {
if (!mergedData.has(item.name)) {
mergedData.set(item.name, { name: item.name });
}
const row = mergedData.get(item.name);
row[item.type] = item.value;
});
// Get all unique types as columns
const types = [...new Set(chart.values.map(item => item.type))];
return {
columns: ['name', ...types],
dataSource: Array.from(mergedData.values()),
};
}
// Use data as is for other formats
return {
columns: Object.keys(chart.values[0]),
dataSource: chart.values,
};
}, [chart]);
// Smart column name formatting
const formatCol = (col: string) =>
columnNameMap?.[col] ||
col
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/\b\w/g, l => l.toUpperCase());
// Cell value formatting
const formatCellValue = (value: any) => {
if (value === null || value === undefined) return '-';
if (typeof value === 'number') {
// Handle percentage and score values (0-100 range)
if (value >= 0 && value <= 100) {
return value.toFixed(2); // Keep two decimal places for scores/percentages
}
// For large numbers, use thousand separators
if (value >= 1000) {
return value.toLocaleString();
}
// For other numbers, limit to 2 decimal places if needed
return Number.isInteger(value) ? value.toString() : value.toFixed(2);
}
return String(value);
};
return (
<div className='flex-1 min-w-0 p-4 bg-white dark:bg-theme-dark-container rounded'>
<div className='h-full'>
<div className='mb-2'>{chart.chart_name}</div>
<div className='opacity-80 text-sm mb-2'>{chart.chart_desc}</div>
<div className='flex-1'>
<Table aria-label='basic table' stripe='odd' hoverRow borderAxis='bothBetween'>
<div className='flex-1 overflow-auto'>
<Table aria-label='dashboard table' stripe='odd' hoverRow borderAxis='bothBetween'>
<thead>
<tr>
{Object.keys(data).map(key => (
<th key={key}>{key}</th>
{columns.map(col => (
<th key={col}>{formatCol(col)}</th>
))}
</tr>
</thead>
<tbody>
{Object.values(data)?.[0]?.map((_, i) => (
<tr key={i}>{Object.keys(data)?.map(k => <td key={k}>{data?.[k]?.[i].value || ''}</td>)}</tr>
{dataSource.map((row, i) => (
<tr key={i}>
{columns.map(col => (
<td key={col}>
{renderCell
? renderCell(row[col as keyof typeof row], row, col)
: formatCellValue(row[col as keyof typeof row])}
</td>
))}
</tr>
))}
</tbody>
</Table>
@ -29,4 +104,4 @@ export default function TableChart({ chart }: { key: string; chart: ChartData })
</div>
</div>
);
}
}

@ -1,17 +1,19 @@
import ChatHeader from '@/new-components/chat/header/ChatHeader';
import { ChatContentContext } from '@/pages/chat';
import { VerticalAlignBottomOutlined, VerticalAlignTopOutlined } from '@ant-design/icons';
import dynamic from 'next/dynamic';
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import React, { forwardRef, useContext, useEffect, useImperativeHandle, useRef, useState } from 'react';
const ChatCompletion = dynamic(() => import('@/new-components/chat/content/ChatCompletion'), { ssr: false });
// eslint-disable-next-line no-empty-pattern
const ChatContentContainer = ({}, ref: React.ForwardedRef<any>) => {
const ChatContentContainer = ({ }, ref: React.ForwardedRef<any>) => {
const scrollRef = useRef<HTMLDivElement>(null);
const [isScrollToTop, setIsScrollToTop] = useState<boolean>(false);
const [showScrollButtons, setShowScrollButtons] = useState<boolean>(false);
const [isAtTop, setIsAtTop] = useState<boolean>(true);
const [isAtBottom, setIsAtBottom] = useState<boolean>(false);
const { history } = useContext(ChatContentContext);
useImperativeHandle(ref, () => {
return scrollRef.current;
@ -62,7 +64,23 @@ const ChatContentContainer = ({}, ref: React.ForwardedRef<any>) => {
scrollRef.current && scrollRef.current.removeEventListener('scroll', handleScroll);
};
}, []);
useEffect(() => {
if (!scrollRef.current) return;
const container = scrollRef.current;
const { scrollTop, scrollHeight, clientHeight } = container;
// dynamic calculate need scroll buffer
const buffer = Math.max(50, container.clientHeight * 0.2);
// auto scroll to bottom when new message is added
const isBottomPos = scrollTop + clientHeight >= scrollHeight - buffer;
if (isBottomPos) {
container.scrollTo({
top: scrollHeight - clientHeight,
behavior: 'smooth',
});
}
}, [history, history[history.length - 1]?.context]);
const scrollToTop = () => {
if (scrollRef.current) {
scrollRef.current.scrollTo({

Loading…
Cancel
Save