feat: Enhance color mapping and chart rendering in Detail component

- Added base and extended color palettes for improved model color mapping.
- Introduced a new `modelToColor` function to dynamically assign colors based on model names.
- Updated the Detail component to utilize the new color mapping for pie and line charts.
- Refactored chart data handling to support dynamic color assignment and improved data visualization.
- Cleaned up unused state variables and optimized data loading logic for better performance.
This commit is contained in:
CalciumIon
2024-12-12 14:56:16 +08:00
parent 79de02b05f
commit ab4c9fdb8f
2 changed files with 240 additions and 175 deletions

View File

@@ -268,6 +268,44 @@ const colors = [
'yellow', 'yellow',
]; ];
// 基础10色色板 (N ≤ 10)
const baseColors = [
'#1664FF', // 主色
'#1AC6FF',
'#FF8A00',
'#3CC780',
'#7442D4',
'#FFC400',
'#304D77',
'#B48DEB',
'#009488',
'#FF7DDA'
];
// 扩展20色色板 (10 < N ≤ 20)
const extendedColors = [
'#1664FF',
'#B2CFFF',
'#1AC6FF',
'#94EFFF',
'#FF8A00',
'#FFCE7A',
'#3CC780',
'#B9EDCD',
'#7442D4',
'#DDC5FA',
'#FFC400',
'#FAE878',
'#304D77',
'#8B959E',
'#B48DEB',
'#EFE3FF',
'#009488',
'#59BAA8',
'#FF7DDA',
'#FFCFEE'
];
export const modelColorMap = { export const modelColorMap = {
'dall-e': 'rgb(147,112,219)', // 深紫色 'dall-e': 'rgb(147,112,219)', // 深紫色
// 'dall-e-2': 'rgb(147,112,219)', // 介于紫色和蓝色之间的色调 // 'dall-e-2': 'rgb(147,112,219)', // 介于紫色和蓝色之间的色调
@@ -312,14 +350,33 @@ export const modelColorMap = {
'claude-2.1': 'rgb(255,209,190)', // 浅橙色(略有区别) 'claude-2.1': 'rgb(255,209,190)', // 浅橙色(略有区别)
}; };
export function modelToColor(modelName) {
// 1. 如果模型在预定义的 modelColorMap 中,使用预定义颜色
if (modelColorMap[modelName]) {
return modelColorMap[modelName];
}
// 2. 生成一个稳定的数字作为索引
let hash = 0;
for (let i = 0; i < modelName.length; i++) {
hash = ((hash << 5) - hash) + modelName.charCodeAt(i);
hash = hash & hash; // Convert to 32-bit integer
}
hash = Math.abs(hash);
// 3. 根据模型名称长度选择不同的色板
const colorPalette = modelName.length > 10 ? extendedColors : baseColors;
// 4. 使用hash值选择颜色
const index = hash % colorPalette.length;
return colorPalette[index];
}
export function stringToColor(str) { export function stringToColor(str) {
let sum = 0; let sum = 0;
// 对字符串中的每个字符进行操作
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
// 将字符的ASCII值加到sum中
sum += str.charCodeAt(i); sum += str.charCodeAt(i);
} }
// 使用模运算得到个位数
let i = sum % colors.length; let i = sum % colors.length;
return colors[i]; return colors[i];
} }

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme'; import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
import { Button, Col, Form, Layout, Row, Spin } from '@douyinfe/semi-ui'; import { Button, Col, Form, Layout, Row, Spin } from '@douyinfe/semi-ui';
import VChart from '@visactor/vchart'; import { VChart } from "@visactor/react-vchart";
import { import {
API, API,
isAdmin, isAdmin,
@@ -17,6 +17,7 @@ import {
renderQuota, renderQuota,
renderQuotaNumberWithDigit, renderQuotaNumberWithDigit,
stringToColor, stringToColor,
modelToColor,
} from '../../helpers/render'; } from '../../helpers/render';
const Detail = (props) => { const Detail = (props) => {
@@ -40,8 +41,6 @@ const Detail = (props) => {
inputs; inputs;
const isAdminUser = isAdmin(); const isAdminUser = isAdmin();
const initialized = useRef(false); const initialized = useRef(false);
const [modelDataChart, setModelDataChart] = useState(null);
const [modelDataPieChart, setModelDataPieChart] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [quotaData, setQuotaData] = useState([]); const [quotaData, setQuotaData] = useState([]);
const [consumeQuota, setConsumeQuota] = useState(0); const [consumeQuota, setConsumeQuota] = useState(0);
@@ -49,23 +48,68 @@ const Detail = (props) => {
const [dataExportDefaultTime, setDataExportDefaultTime] = useState( const [dataExportDefaultTime, setDataExportDefaultTime] = useState(
localStorage.getItem('data_export_default_time') || 'hour', localStorage.getItem('data_export_default_time') || 'hour',
); );
const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]);
const handleInputChange = (value, name) => { const [lineData, setLineData] = useState([]);
if (name === 'data_export_default_time') { const [spec_pie, setSpecPie] = useState({
setDataExportDefaultTime(value); type: 'pie',
return; data: [{
} id: 'id0',
setInputs((inputs) => ({ ...inputs, [name]: value })); values: pieData
}; }],
outerRadius: 0.8,
const spec_line = { innerRadius: 0.5,
type: 'bar', padAngle: 0.6,
data: [ valueField: 'value',
{ categoryField: 'type',
id: 'barData', pie: {
values: [], style: {
cornerRadius: 10,
}, },
], state: {
hover: {
outerRadius: 0.85,
stroke: '#000',
lineWidth: 1,
},
selected: {
outerRadius: 0.85,
stroke: '#000',
lineWidth: 1,
},
},
},
title: {
visible: true,
text: '模型调用次数占比',
subtext: `总计:${renderNumber(times)}`,
},
legends: {
visible: true,
orient: 'left',
},
label: {
visible: true,
},
tooltip: {
mark: {
content: [
{
key: (datum) => datum['type'],
value: (datum) => renderNumber(datum['value']),
},
],
},
},
color: {
specified: modelColorMap,
},
});
const [spec_line, setSpecLine] = useState({
type: 'bar',
data: [{
id: 'barData',
values: lineData
}],
xField: 'Time', xField: 'Time',
yField: 'Usage', yField: 'Usage',
seriesField: 'Model', seriesField: 'Model',
@@ -77,7 +121,7 @@ const Detail = (props) => {
title: { title: {
visible: true, visible: true,
text: '模型消耗分布', text: '模型消耗分布',
subtext: '0', subtext: `总计:${renderQuota(consumeQuota, 2)}`,
}, },
bar: { bar: {
// The state style of bar // The state style of bar
@@ -129,196 +173,160 @@ const Detail = (props) => {
color: { color: {
specified: modelColorMap, specified: modelColorMap,
}, },
});
// 添加一个新的状态来存储模型-颜色映射
const [modelColors, setModelColors] = useState({});
const handleInputChange = (value, name) => {
if (name === 'data_export_default_time') {
setDataExportDefaultTime(value);
return;
}
setInputs((inputs) => ({ ...inputs, [name]: value }));
}; };
const spec_pie = { const loadQuotaData = async () => {
type: 'pie',
data: [
{
id: 'id0',
values: [{ type: 'null', value: '0' }],
},
],
outerRadius: 0.8,
innerRadius: 0.5,
padAngle: 0.6,
valueField: 'value',
categoryField: 'type',
pie: {
style: {
cornerRadius: 10,
},
state: {
hover: {
outerRadius: 0.85,
stroke: '#000',
lineWidth: 1,
},
selected: {
outerRadius: 0.85,
stroke: '#000',
lineWidth: 1,
},
},
},
title: {
visible: true,
text: '模型调用次数占比',
},
legends: {
visible: true,
orient: 'left',
},
label: {
visible: true,
},
tooltip: {
mark: {
content: [
{
key: (datum) => datum['type'],
value: (datum) => renderNumber(datum['value']),
},
],
},
},
color: {
specified: modelColorMap,
},
};
const loadQuotaData = async (lineChart, pieChart) => {
setLoading(true); setLoading(true);
try {
let url = ''; let url = '';
let localStartTimestamp = Date.parse(start_timestamp) / 1000; let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000; let localEndTimestamp = Date.parse(end_timestamp) / 1000;
if (isAdminUser) { if (isAdminUser) {
url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`; url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
} else { } else {
url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`; url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
} }
const res = await API.get(url); const res = await API.get(url);
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
setQuotaData(data); setQuotaData(data);
if (data.length === 0) { if (data.length === 0) {
data.push({ data.push({
count: 0, count: 0,
model_name: '无数据', model_name: '无数据',
quota: 0, quota: 0,
created_at: now.getTime() / 1000, created_at: now.getTime() / 1000,
});
}
// 根据dataExportDefaultTime重制时间粒度
let timeGranularity = 3600;
if (dataExportDefaultTime === 'day') {
timeGranularity = 86400;
} else if (dataExportDefaultTime === 'week') {
timeGranularity = 604800;
}
// sort created_at
data.sort((a, b) => a.created_at - b.created_at);
data.forEach((item) => {
item['created_at'] =
Math.floor(item['created_at'] / timeGranularity) * timeGranularity;
}); });
updateChartData(data);
} else {
showError(message);
} }
// 根据dataExportDefaultTime重制时间粒度 } finally {
let timeGranularity = 3600; setLoading(false);
if (dataExportDefaultTime === 'day') {
timeGranularity = 86400;
} else if (dataExportDefaultTime === 'week') {
timeGranularity = 604800;
}
// sort created_at
data.sort((a, b) => a.created_at - b.created_at);
data.forEach((item) => {
item['created_at'] =
Math.floor(item['created_at'] / timeGranularity) * timeGranularity;
});
updateChart(lineChart, pieChart, data);
} else {
showError(message);
} }
setLoading(false);
}; };
const refresh = async () => { const refresh = async () => {
await loadQuotaData(modelDataChart, modelDataPieChart); await loadQuotaData();
}; };
const initChart = async () => { const initChart = async () => {
let lineChart = modelDataChart; await loadQuotaData();
if (!modelDataChart) {
lineChart = new VChart(spec_line, { dom: 'model_data' });
setModelDataChart(lineChart);
lineChart.renderAsync();
}
let pieChart = modelDataPieChart;
if (!modelDataPieChart) {
pieChart = new VChart(spec_pie, { dom: 'model_pie' });
setModelDataPieChart(pieChart);
pieChart.renderAsync();
}
console.log('init vchart');
await loadQuotaData(lineChart, pieChart);
}; };
const updateChart = (lineChart, pieChart, data) => { const updateChartData = (data) => {
if (isAdminUser) { let newPieData = [];
// 将所有用户合并 let newLineData = [];
} let totalQuota = 0;
let pieData = []; let totalTimes = 0;
let lineData = []; let uniqueModels = new Set();
let consumeQuota = 0;
let times = 0; // 首先收集所有唯一的模型名称
data.forEach(item => uniqueModels.add(item.model_name));
// 为每个唯一的模型生成或获取颜色
const newModelColors = {};
Array.from(uniqueModels).forEach((modelName) => {
// 优先使用 modelColorMap 中的颜色,然后是已存在的颜色,最后使用新的颜色生成函数
newModelColors[modelName] = modelColorMap[modelName] ||
modelColors[modelName] ||
modelToColor(modelName); // 使用新的颜色生成函数替代 stringToColor
});
setModelColors(newModelColors);
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
const item = data[i]; const item = data[i];
consumeQuota += item.quota; totalQuota += item.quota;
times += item.count; totalTimes += item.count;
// 合并model_name // 合并model_name
let pieItem = pieData.find((it) => it.type === item.model_name); let pieItem = newPieData.find((it) => it.type === item.model_name);
if (pieItem) { if (pieItem) {
pieItem.value += item.count; pieItem.value += item.count;
} else { } else {
pieData.push({ newPieData.push({
type: item.model_name, type: item.model_name,
value: item.count, value: item.count,
}); });
} }
// 合并created_at和model_name 为 lineData, created_at 数据类型是小时的时间戳 // 合并created_at和model_name 为 lineData
// 转换日期格式
let createTime = timestamp2string1( let createTime = timestamp2string1(
item.created_at, item.created_at,
dataExportDefaultTime, dataExportDefaultTime,
); );
let lineItem = lineData.find( let lineItem = newLineData.find(
(it) => it.Time === createTime && it.Model === item.model_name, (it) => it.Time === createTime && it.Model === item.model_name,
); );
if (lineItem) { if (lineItem) {
lineItem.Usage += parseFloat(getQuotaWithUnit(item.quota)); lineItem.Usage += parseFloat(getQuotaWithUnit(item.quota));
} else { } else {
lineData.push({ newLineData.push({
Time: createTime, Time: createTime,
Model: item.model_name, Model: item.model_name,
Usage: parseFloat(getQuotaWithUnit(item.quota)), Usage: parseFloat(getQuotaWithUnit(item.quota)),
}); });
} }
} }
setConsumeQuota(consumeQuota);
setTimes(times);
// sort by count // sort by count
pieData.sort((a, b) => b.value - a.value); newPieData.sort((a, b) => b.value - a.value);
spec_pie.title.subtext = `总计:${renderNumber(times)}`;
spec_pie.data[0].values = pieData;
spec_line.title.subtext = `总计:${renderQuota(consumeQuota, 2)}`; // 更新图表配置和数据
spec_line.data[0].values = lineData; setSpecPie(prev => ({
pieChart.updateSpec(spec_pie); ...prev,
lineChart.updateSpec(spec_line); data: [{ id: 'id0', values: newPieData }],
title: {
...prev.title,
subtext: `总计:${renderNumber(totalTimes)}`
},
color: {
specified: newModelColors
}
}));
// pieChart.updateData('id0', pieData); setSpecLine(prev => ({
// lineChart.updateData('barData', lineData); ...prev,
pieChart.reLayout(); data: [{ id: 'barData', values: newLineData }],
lineChart.reLayout(); title: {
...prev.title,
subtext: `总计:${renderQuota(totalQuota, 2)}`
},
color: {
specified: newModelColors
}
}));
setPieData(newPieData);
setLineData(newLineData);
setConsumeQuota(totalQuota);
setTimes(totalTimes);
}; };
useEffect(() => { useEffect(() => {
// setDataExportDefaultTime(localStorage.getItem('data_export_default_time'));
// if (dataExportDefaultTime === 'day') {
// // 设置开始时间为7天前
// let st = timestamp2string(now.getTime() / 1000 - 86400 * 7)
// inputs.start_timestamp = st;
// formRef.current.formApi.setValue('start_timestamp', st);
// }
if (!initialized.current) { if (!initialized.current) {
initVChartSemiTheme({ initVChartSemiTheme({
isWatchingThemeSwitch: true, isWatchingThemeSwitch: true,
@@ -405,16 +413,16 @@ const Detail = (props) => {
</Form> </Form>
<Spin spinning={loading}> <Spin spinning={loading}>
<div style={{ height: 500 }}> <div style={{ height: 500 }}>
<div <VChart
id='model_pie' spec={spec_pie}
style={{ width: '100%', minWidth: 100 }} option={{ mode: "desktop-browser" }}
></div> />
</div> </div>
<div style={{ height: 500 }}> <div style={{ height: 500 }}>
<div <VChart
id='model_data' spec={spec_line}
style={{ width: '100%', minWidth: 100 }} option={{ mode: "desktop-browser" }}
></div> />
</div> </div>
</Spin> </Spin>
</Layout.Content> </Layout.Content>