feat: 新增饼图/雷达图图表类型

- 单图模式图表类型新增:饼图(环形占比,第一列=名称/第一系列=数值)、雷达图(多维度对比,第一列=维度/每系列=多边形)
- 多图合并的每张图也可选饼图/雷达图
- 后端 /api/chart 与 /api/combine 的 chartType 支持 pie/radar
- 饼图/雷达图自动隐藏坐标轴/网格/双轴等不适用配置
This commit is contained in:
2026-08-19 17:12:18 +08:00
parent d7c7c08407
commit 7a03ecc063
5 changed files with 231 additions and 6 deletions
+2 -2
View File
@@ -32,7 +32,7 @@ Content-Type: application/json
| 参数 | 类型 | 必填 | 默认值 | 说明 | | 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|:----:|--------|------| |------|------|:----:|--------|------|
| `data` | string | ✅ | — | CSV 格式数据,`\n` 换行,第一行表头,第一列横坐标 | | `data` | string | ✅ | — | CSV 格式数据,`\n` 换行,第一行表头,第一列横坐标 |
| `chartType` | string | — | `bar` | 图表类型:`bar` / `line` / `bar-line` | | `chartType` | string | — | `bar` | 图表类型:`bar` / `line` / `bar-line` / `pie` / `radar` |
| `title` | string | — | `""` | 图表标题 | | `title` | string | — | `""` | 图表标题 |
| `theme` | string | — | `default` | 主题风格:`default` / `dark` / `macarons` / `gradient` / `retro` | | `theme` | string | — | `default` | 主题风格:`default` / `dark` / `macarons` / `gradient` / `retro` |
| `showLegend` | boolean | — | `true` | 显示图例 | | `showLegend` | boolean | — | `true` | 显示图例 |
@@ -181,7 +181,7 @@ Content-Type: application/json
> 兼容旧参数:`chart1` + `chart2` 仍可传(等价于 `charts: [chart1, chart2]`)。 > 兼容旧参数:`chart1` + `chart2` 仍可传(等价于 `charts: [chart1, chart2]`)。
每个子图配置支持:`data`CSV)、`chartType`bar/line/bar-line)、`title``theme``showLegend``showGrid``showLabel``stackMode``smoothLine``width``height` 每个子图配置支持:`data`CSV)、`chartType`bar/line/bar-line/pie/radar)、`title``theme``showLegend``showGrid``showLabel``stackMode``smoothLine``width``height`
### curl 示例 ### curl 示例
+2
View File
@@ -11,6 +11,8 @@
- **柱状图** - 支持单系列/多系列对比 - **柱状图** - 支持单系列/多系列对比
- **折线图** - 支持平滑曲线、面积填充 - **折线图** - 支持平滑曲线、面积填充
- **混合图** - 柱状图+折线图组合展示 - **混合图** - 柱状图+折线图组合展示
- **饼图** - 环形占比展示(第一列=名称,第一系列=数值)
- **雷达图** - 多维度对比(第一列=维度,每个系列=一个多边形)
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线) - **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线)
### 📋 表格功能 ### 📋 表格功能
+156 -1
View File
@@ -157,6 +157,91 @@ function initChart() {
}); });
} }
// ===== 饼图/雷达图 option 构建 =====
function buildPieRadarOption(chartType) {
if (!parsedData || parsedData.seriesNames.length === 0) return null;
const title = document.getElementById('chartTitle').value;
const theme = document.getElementById('themeStyle').value;
const showLegend = document.getElementById('showLegend').checked;
const showLabel = document.getElementById('showLabel').checked;
const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff';
const textColor = theme === 'dark' ? '#e0e0e0' : '#333333';
const palette = colorPalettes[theme] || colorPalettes.default;
const colors = seriesOrder.map((origIdx, di) => seriesColors[origIdx] || palette[di % palette.length]);
const baseTitle = title ? {
text: title, left: 'center', top: 10,
textStyle: { color: textColor, fontSize: 18, fontWeight: 600 }
} : undefined;
if (chartType === 'pie') {
// 饼图:第一列=名称,第一个系列=数值
const sName = parsedData.seriesNames[0];
const data = parsedData.seriesData[sName];
const pieData = parsedData.categories.map((c, i) => ({ name: c, value: data[i] || 0 }));
return {
backgroundColor: bgColor,
title: baseTitle,
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: showLegend ? {
orient: 'vertical', left: 'left', top: title ? 50 : 20,
textStyle: { color: textColor, fontSize: 12 }
} : { show: false },
color: colors,
series: [{
type: 'pie',
radius: ['38%', '68%'],
center: ['52%', '55%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: bgColor, borderWidth: 2 },
label: { show: showLabel, formatter: '{b}: {d}%', color: textColor },
labelLine: { show: showLabel },
data: pieData
}]
};
} else {
// 雷达图:第一列=维度名,每个系列=一个雷达多边形
const indicator = parsedData.categories.map(c => {
let max = 0;
parsedData.seriesNames.forEach(n => {
parsedData.seriesData[n].forEach(v => { if (v > max) max = v; });
});
return { name: c, max: Math.ceil(max * 1.2) || 100 };
});
return {
backgroundColor: bgColor,
title: baseTitle,
tooltip: { trigger: 'item' },
legend: showLegend ? {
orient: 'horizontal', left: 'center', top: title ? 48 : 15,
textStyle: { color: textColor, fontSize: 12 }
} : { show: false },
color: colors,
radar: {
indicator,
radius: '62%',
center: ['50%', '55%'],
splitNumber: 5,
axisName: { color: textColor, fontSize: 12 },
splitLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } },
splitArea: {
areaStyle: {
color: theme === 'dark' ? ['rgba(79,195,247,0.03)', 'rgba(79,195,247,0.06)'] : ['rgba(79,70,229,0.03)', 'rgba(79,70,229,0.06)']
}
},
axisLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } }
},
series: parsedData.seriesNames.map(n => ({
type: 'radar',
name: n,
data: [{ value: parsedData.seriesData[n], name: n }],
symbolSize: 4,
areaStyle: { opacity: 0.15 }
}))
};
}
}
// ===== 更新图表 ===== // ===== 更新图表 =====
function updateChart() { function updateChart() {
if (!parsedData || !chartInstance) return; if (!parsedData || !chartInstance) return;
@@ -174,6 +259,16 @@ function updateChart() {
const leftAxisName = document.getElementById('leftAxisName').value; const leftAxisName = document.getElementById('leftAxisName').value;
const rightAxisName = document.getElementById('rightAxisName').value; const rightAxisName = document.getElementById('rightAxisName').value;
// 饼图/雷达图:走专用构建逻辑(无坐标轴/网格,不支持双轴/堆叠/分割)
if (chartType === 'pie' || chartType === 'radar') {
const option = buildPieRadarOption(chartType);
if (option) {
chartInstance.setOption(option, true);
refreshPreview();
}
return;
}
// 显示/隐藏分割配置 // 显示/隐藏分割配置
document.getElementById('splitConfig').style.display = enableSplit ? 'block' : 'none'; document.getElementById('splitConfig').style.display = enableSplit ? 'block' : 'none';
@@ -1058,6 +1153,66 @@ function buildCombineChartOption(dataText, cfg) {
const axisLineColor = theme === 'dark' ? '#444' : '#ddd'; const axisLineColor = theme === 'dark' ? '#444' : '#ddd';
const palette = colorPalettes[theme] || colorPalettes.default; const palette = colorPalettes[theme] || colorPalettes.default;
// 饼图/雷达图:专用构建逻辑
if (chartType === 'pie' || chartType === 'radar') {
if (chartType === 'pie') {
const sName = parsed.seriesNames[0];
const data = parsed.seriesData[sName];
const pieData = parsed.categories.map((c, i) => ({ name: c, value: data[i] || 0 }));
return {
backgroundColor: bgColor,
title: title ? { text: title, left: 'center', top: 10, textStyle: { color: textColor, fontSize: 16, fontWeight: 600 } } : undefined,
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: showLegend ? { orient: 'vertical', left: 'left', top: title ? 40 : 10, textStyle: { color: textColor, fontSize: 12 } } : { show: false },
color: palette,
series: [{
type: 'pie',
radius: ['38%', '68%'],
center: ['55%', '55%'],
itemStyle: { borderRadius: 6, borderColor: bgColor, borderWidth: 2 },
label: { show: showLabel, formatter: '{b}: {d}%', color: textColor },
labelLine: { show: showLabel },
data: pieData
}],
animation: false
};
} else {
const indicator = parsed.categories.map(c => {
let max = 0;
parsed.seriesNames.forEach(n => { parsed.seriesData[n].forEach(v => { if (v > max) max = v; }); });
return { name: c, max: Math.ceil(max * 1.2) || 100 };
});
return {
backgroundColor: bgColor,
title: title ? { text: title, left: 'center', top: 10, textStyle: { color: textColor, fontSize: 16, fontWeight: 600 } } : undefined,
tooltip: { trigger: 'item' },
legend: showLegend ? { orient: 'horizontal', left: 'center', top: title ? 38 : 10, textStyle: { color: textColor, fontSize: 12 } } : { show: false },
color: palette,
radar: {
indicator,
radius: '62%',
center: ['50%', '55%'],
splitNumber: 5,
axisName: { color: textColor, fontSize: 11 },
splitLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } },
splitArea: {
areaStyle: {
color: theme === 'dark' ? ['rgba(79,195,247,0.03)', 'rgba(79,195,247,0.06)'] : ['rgba(79,70,229,0.03)', 'rgba(79,70,229,0.06)']
}
},
axisLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } }
},
series: parsed.seriesNames.map(n => ({
type: 'radar', name: n,
data: [{ value: parsed.seriesData[n], name: n }],
symbolSize: 4,
areaStyle: { opacity: 0.15 }
})),
animation: false
};
}
}
const series = parsed.seriesNames.map((name, idx) => { const series = parsed.seriesNames.map((name, idx) => {
const color = palette[idx % palette.length]; const color = palette[idx % palette.length];
let type = chartType === 'bar-line' ? (idx % 2 === 0 ? 'bar' : 'line') : chartType; let type = chartType === 'bar-line' ? (idx % 2 === 0 ? 'bar' : 'line') : chartType;
@@ -1177,7 +1332,7 @@ function initCombineCharts() {
renderCombineCharts(); renderCombineCharts();
} }
const COMBINE_TYPES = [['bar', '柱状图'], ['line', '折线图'], ['bar-line', '柱状图+折线图混合']]; const COMBINE_TYPES = [['bar', '柱状图'], ['line', '折线图'], ['bar-line', '柱状图+折线图混合'], ['pie', '饼图'], ['radar', '雷达图']];
const COMBINE_THEMES = [['default', '默认'], ['dark', '深色'], ['macarons', '马卡龙'], ['gradient', '渐变'], ['retro', '复古'], ['ocean', '海洋'], ['forest', '森林'], ['sunset', '日落'], ['lavender', '薰衣草'], ['minimal', '极简'], ['cherry', '樱花'], ['midnight', '午夜'], ['gold', '金色'], ['coral', '珊瑚'], ['mint', '薄荷'], ['slate', '石板灰'], ['sky', '天空蓝'], ['rose', '玫瑰红'], ['amber', '琥珀黄'], ['emerald', '翡翠绿'], ['indigo', '靛蓝色'], ['stone', '石灰白']]; const COMBINE_THEMES = [['default', '默认'], ['dark', '深色'], ['macarons', '马卡龙'], ['gradient', '渐变'], ['retro', '复古'], ['ocean', '海洋'], ['forest', '森林'], ['sunset', '日落'], ['lavender', '薰衣草'], ['minimal', '极简'], ['cherry', '樱花'], ['midnight', '午夜'], ['gold', '金色'], ['coral', '珊瑚'], ['mint', '薄荷'], ['slate', '石板灰'], ['sky', '天空蓝'], ['rose', '玫瑰红'], ['amber', '琥珀黄'], ['emerald', '翡翠绿'], ['indigo', '靛蓝色'], ['stone', '石灰白']];
function escHtml(str) { function escHtml(str) {
+2
View File
@@ -56,6 +56,8 @@ C, 20, 30, 40</pre>
<option value="bar">柱状图</option> <option value="bar">柱状图</option>
<option value="line">折线图</option> <option value="line">折线图</option>
<option value="bar-line">柱状图+折线图混合</option> <option value="bar-line">柱状图+折线图混合</option>
<option value="pie">饼图</option>
<option value="radar">雷达图</option>
</select> </select>
</div> </div>
+69 -3
View File
@@ -130,6 +130,72 @@ function buildChartOption(params) {
const textColor = theme === 'dark' ? '#e0e0e0' : '#333333'; const textColor = theme === 'dark' ? '#e0e0e0' : '#333333';
const axisLineColor = theme === 'dark' ? '#444' : '#ddd'; const axisLineColor = theme === 'dark' ? '#444' : '#ddd';
// 饼图/雷达图:专用构建逻辑(无坐标轴/网格)
if (chartType === 'pie' || chartType === 'radar') {
const bgC = theme === 'dark' ? '#1a1a2e' : '#ffffff';
const txtC = theme === 'dark' ? '#e0e0e0' : '#333333';
const tTitle = title ? { text: title, left: 'center', top: 15, textStyle: { color: txtC, fontSize: 18, fontWeight: 600 } } : undefined;
if (chartType === 'pie') {
// 饼图:第一列=名称,第一个系列=数值
const sName = parsedData.seriesNames[0];
const data = parsedData.seriesData[sName];
const pieData = parsedData.categories.map((c, i) => ({ name: c, value: data[i] || 0 }));
return {
backgroundColor: bgC,
title: tTitle,
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: showLegend ? { orient: 'vertical', left: 'left', top: title ? 50 : 20, textStyle: { color: txtC, fontSize: 12 } } : { show: false },
color: seriesColorsArr,
series: [{
type: 'pie',
radius: ['38%', '68%'],
center: ['52%', '55%'],
itemStyle: { borderRadius: 6, borderColor: bgC, borderWidth: 2 },
label: { show: showLabel, formatter: '{b}: {d}%', color: txtC },
labelLine: { show: showLabel },
data: pieData
}],
animation: false
};
} else {
// 雷达图:第一列=维度名,每个系列=一个雷达多边形
const indicator = parsedData.categories.map(c => {
let max = 0;
parsedData.seriesNames.forEach(n => { parsedData.seriesData[n].forEach(v => { if (v > max) max = v; }); });
return { name: c, max: Math.ceil(max * 1.2) || 100 };
});
return {
backgroundColor: bgC,
title: tTitle,
tooltip: { trigger: 'item' },
legend: showLegend ? { orient: 'horizontal', left: 'center', top: title ? 48 : 15, textStyle: { color: txtC, fontSize: 12 } } : { show: false },
color: seriesColorsArr,
radar: {
indicator,
radius: '62%',
center: ['50%', '55%'],
splitNumber: 5,
axisName: { color: txtC, fontSize: 12 },
splitLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } },
splitArea: {
areaStyle: {
color: theme === 'dark' ? ['rgba(79,195,247,0.03)', 'rgba(79,195,247,0.06)'] : ['rgba(79,70,229,0.03)', 'rgba(79,70,229,0.06)']
}
},
axisLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } }
},
series: parsedData.seriesNames.map(n => ({
type: 'radar',
name: n,
data: [{ value: parsedData.seriesData[n], name: n }],
symbolSize: 4,
areaStyle: { opacity: 0.15 }
})),
animation: false
};
}
}
// 构建系列 // 构建系列
const series = parsedData.seriesNames.map((name, idx) => { const series = parsedData.seriesNames.map((name, idx) => {
const dataArr = parsedData.seriesData[name]; const dataArr = parsedData.seriesData[name];
@@ -928,7 +994,7 @@ app.get('/api/health', (req, res) => {
res.json({ res.json({
status: 'ok', status: 'ok',
service: 'data-chart-tool', service: 'data-chart-tool',
version: '1.13.1', version: '1.14.0',
endpoints: { endpoints: {
'POST /api/chart': '生成图表图片(JSON body', 'POST /api/chart': '生成图表图片(JSON body',
'GET /api/chart': '生成图表图片(URL 参数)', 'GET /api/chart': '生成图表图片(URL 参数)',
@@ -943,7 +1009,7 @@ app.get('/api/health', (req, res) => {
app.get('/api/docs', (req, res) => { app.get('/api/docs', (req, res) => {
res.json({ res.json({
name: '数据可视化图表生成器 API', name: '数据可视化图表生成器 API',
version: '1.13.1', version: '1.14.0',
endpoints: [ endpoints: [
{ {
method: 'POST', method: 'POST',
@@ -952,7 +1018,7 @@ app.get('/api/docs', (req, res) => {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
params: { params: {
data: { type: 'string', required: true, description: 'CSV 格式数据(第一行表头,第一列横坐标)' }, data: { type: 'string', required: true, description: 'CSV 格式数据(第一行表头,第一列横坐标)' },
chartType: { type: 'string', default: 'bar', options: ['bar', 'line', 'bar-line'], description: '图表类型' }, chartType: { type: 'string', default: 'bar', options: ['bar', 'line', 'bar-line', 'pie', 'radar'], description: '图表类型' },
title: { type: 'string', default: '', description: '图表标题' }, title: { type: 'string', default: '', description: '图表标题' },
theme: { type: 'string', default: 'default', options: ['default', 'dark', 'macarons', 'gradient', 'retro', 'ocean', 'forest', 'sunset', 'lavender', 'minimal', 'cherry', 'midnight', 'gold', 'coral', 'mint'], description: '主题风格' }, theme: { type: 'string', default: 'default', options: ['default', 'dark', 'macarons', 'gradient', 'retro', 'ocean', 'forest', 'sunset', 'lavender', 'minimal', 'cherry', 'midnight', 'gold', 'coral', 'mint'], description: '主题风格' },
showLegend: { type: 'boolean', default: true, description: '是否显示图例' }, showLegend: { type: 'boolean', default: true, description: '是否显示图例' },