Files
data-chart-tool/server.js
T
hz4th_coder 7a03ecc063 feat: 新增饼图/雷达图图表类型
- 单图模式图表类型新增:饼图(环形占比,第一列=名称/第一系列=数值)、雷达图(多维度对比,第一列=维度/每系列=多边形)
- 多图合并的每张图也可选饼图/雷达图
- 后端 /api/chart 与 /api/combine 的 chartType 支持 pie/radar
- 饼图/雷达图自动隐藏坐标轴/网格/双轴等不适用配置
2026-08-19 17:12:18 +08:00

1282 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const cors = require('cors');
const { createCanvas, registerFont, loadImage } = require('@napi-rs/canvas');
const echarts = require('echarts');
const path = require('path');
// 注册中文字体
try {
const { GlobalFonts } = require('@napi-rs/canvas');
GlobalFonts.registerFromPath('/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', 'Noto Sans CJK SC');
GlobalFonts.registerFromPath('/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc', 'Noto Sans CJK SC');
console.log('中文字体注册成功');
} catch (e) {
console.warn('中文字体注册失败,将使用系统默认字体:', e.message);
}
const app = express();
const PORT = process.env.PORT || 16016;
// 中间件
app.use(cors());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// 静态文件(前端页面)
app.use(express.static(path.join(__dirname)));
// ===== 预设颜色方案 =====
const colorPalettes = {
default: ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc'],
dark: ['#4fc3f7', '#81c784', '#fff176', '#ff8a65', '#ba68c8', '#4dd0e1', '#ffab91', '#aed581', '#f48fb1'],
macarons: ['#2ec7c9', '#b6a2de', '#5ab1ef', '#ffb980', '#d87a80', '#8d98b3', '#e5cf0d', '#97b552', '#95706d'],
gradient: ['#7f7fd5', '#86a8e7', '#91eae4', '#ff6b6b', '#feca57', '#48dbfb', '#ff9ff3', '#54a0ff', '#5f27cd'],
retro: ['#d4a5a5', '#95b9c7', '#f6e8c3', '#dfceb4', '#a4c3b5', '#c9b1ff', '#f5c7b8', '#b8d4e3', '#e8c8a0'],
ocean: ['#0077b6', '#00b4d8', '#90e0ef', '#caf0f8', '#023e8a', '#48cae4', '#ade8f4', '#0096c7', '#03045e'],
forest: ['#2d6a4f', '#40916c', '#52b788', '#74c69d', '#95d5b2', '#b7e4c7', '#d8f3dc', '#1b4332', '#344e41'],
sunset: ['#e85d04', '#f48c06', '#faa307', '#ffba08', '#dc2f02', '#d00000', '#9d0208', '#6a040f', '#370617'],
lavender: ['#7c3aed', '#8b5cf6', '#a78bfa', '#c4b5fd', '#ddd6fe', '#ede9fe', '#5b21b6', '#4c1d95', '#6d28d9'],
minimal: ['#111827', '#374151', '#6b7280', '#9ca3af', '#d1d5db', '#e5e7eb', '#f3f4f6', '#4b5563', '#1f2937'],
cherry: ['#e91e63', '#f06292', '#f48fb1', '#f8bbd0', '#fce4ec', '#c2185b', '#ad1457', '#880e4f', '#d81b60'],
midnight: ['#6c63ff', '#5a52d5', '#483fb5', '#7c79ff', '#3d3d5c', '#8b89ff', '#9d9bff', '#2c2c4a', '#b3b2ff'],
gold: ['#b8860b', '#daa520', '#f0c040', '#ffd700', '#ffe066', '#996515', '#cc9900', '#e6b800', '#8b6914'],
coral: ['#ff6f61', '#ff8a70', '#ffa07a', '#ffab91', '#ffccbc', '#e64a19', '#f4511e', '#ff5722', '#d84315'],
mint: ['#00bfa5', '#26a69a', '#4db6ac', '#80cbc4', '#b2dfdb', '#00897b', '#00796b', '#004d40', '#009688']
};
// ===== 数据解析(与前端一致) =====
function parseData(rawText) {
const lines = rawText.trim().split('\n').filter(l => l.trim());
if (lines.length < 2) {
throw new Error('数据至少需要包含表头和一行数据');
}
// 自动检测分隔符
let delimiter = ',';
if (lines[0].includes('\t')) {
delimiter = '\t';
} else if (lines[0].split('|').length > lines[0].split(',').length) {
delimiter = '|';
}
const rows = lines.map(line => {
return line.split(delimiter).map(cell => cell.trim());
});
const headers = rows[0];
const categories = [];
const seriesData = {};
for (let i = 1; i < headers.length; i++) {
seriesData[headers[i]] = [];
}
for (let i = 1; i < rows.length; i++) {
categories.push(rows[i][0]);
for (let j = 1; j < rows[i].length && j < headers.length; j++) {
const val = parseFloat(rows[i][j]);
seriesData[headers[j]].push(isNaN(val) ? 0 : val);
}
}
return {
categories,
seriesNames: headers.slice(1),
seriesData
};
}
// ===== 颜色工具 =====
function adjustColor(hex, amount) {
hex = hex.replace('#', '');
const num = parseInt(hex, 16);
let r = Math.min(255, ((num >> 16) & 0xff) + amount);
let g = Math.min(255, ((num >> 8) & 0xff) + amount);
let b = Math.min(255, (num & 0xff) + amount);
return `rgb(${r},${g},${b})`;
}
// ===== 构建 ECharts option =====
function buildChartOption(params) {
const {
data,
chartType = 'bar',
title = '',
theme = 'default',
showLegend = true,
showGrid = true,
showLabel = false,
stackMode = false,
smoothLine = true,
enableSplit = false,
splitIndex = 3,
leftLabel = '左侧',
rightLabel = '右侧',
splitStyle = 'solid',
seriesColors: customColors = null,
dualYAxis = false,
rightAxisSeries = null,
leftAxisName = '',
rightAxisName = '',
seriesTypes = null
} = params;
const parsedData = parseData(data);
const palette = colorPalettes[theme] || colorPalettes.default;
const seriesColorsArr = customColors || parsedData.seriesNames.map((_, i) => palette[i % palette.length]);
// 背景色和文字色
const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff';
const textColor = theme === 'dark' ? '#e0e0e0' : '#333333';
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 dataArr = parsedData.seriesData[name];
const color = seriesColorsArr[idx] || palette[idx % palette.length];
let type = chartType === 'bar-line'
? (idx % 2 === 0 ? 'bar' : 'line')
: chartType;
// 每系列独立类型覆盖(seriesTypes 数组按系列顺序对应)
if (Array.isArray(seriesTypes) && seriesTypes[idx] && seriesTypes[idx] !== 'auto') {
type = seriesTypes[idx];
}
const seriesItem = {
name: name,
type: type,
data: [...dataArr],
itemStyle: { color: color },
emphasis: {
focus: 'series',
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(0,0,0,0.3)'
}
}
};
// 双Y轴:rightAxisSeries 指定右轴系列名
if (dualYAxis) {
const rightNames = Array.isArray(rightAxisSeries) ? rightAxisSeries : (rightAxisSeries ? [rightAxisSeries] : []);
seriesItem.yAxisIndex = rightNames.includes(name) ? 1 : 0;
}
if (stackMode) {
seriesItem.stack = 'total';
}
if (type === 'line') {
seriesItem.smooth = smoothLine;
seriesItem.lineStyle = { width: 3 };
seriesItem.symbolSize = 8;
seriesItem.areaStyle = theme === 'gradient' ? { opacity: 0.15 } : undefined;
}
if (type === 'bar') {seriesItem.barMaxWidth = 40;
seriesItem.itemStyle.borderRadius = stackMode ? [0, 0, 0, 0] : [4, 4, 0, 0];
}
if (showLabel) {
seriesItem.label = {
show: true,
position: 'top',
fontSize: 11,
color: textColor,
formatter: (p) => {
if (p.value >= 10000) return (p.value / 10000).toFixed(1) + 'w';
if (p.value >= 1000) return (p.value / 1000).toFixed(1) + 'k';
return p.value;
}
};
}
return seriesItem;
});
// 区域分割
if (enableSplit && splitIndex < parsedData.categories.length && series.length > 0) {
const splitLineColor = theme === 'dark' ? '#ff6b6b' : '#e74c3c';
series[0].markArea = {
silent: true,
data: [
[
{
name: leftLabel,
xAxis: parsedData.categories[0],
itemStyle: {
color: theme === 'dark' ? 'rgba(79,195,247,0.06)' : 'rgba(79,70,229,0.05)',
borderWidth: 0
},
label: {
show: true,
position: 'insideTop',
fontSize: 13,
fontWeight: 'bold',
color: theme === 'dark' ? '#4fc3f7' : '#4f46e5',
offset: [0, 10]
}
},
{ xAxis: parsedData.categories[splitIndex - 1] }
],
[
{
name: rightLabel,
xAxis: parsedData.categories[splitIndex],
itemStyle: {
color: theme === 'dark' ? 'rgba(255,107,107,0.06)' : 'rgba(239,68,68,0.05)',
borderWidth: 0
},
label: {
show: true,
position: 'insideTop',
fontSize: 13,
fontWeight: 'bold',
color: theme === 'dark' ? '#ff6b6b' : '#ef4444',
offset: [0, 10]
}
},
{ xAxis: parsedData.categories[parsedData.categories.length - 1] }
]
]
};
}
// 完整 option
const option = {
backgroundColor: bgColor,
title: title ? {
text: title,
left: 'center',
top: 15,
textStyle: {
color: textColor,
fontSize: 18,
fontWeight: 600
}
} : undefined,
tooltip: { show: false },
legend: showLegend ? {
show: true,
top: title ? 50 : 15,
textStyle: { color: textColor, fontSize: 12 },
itemGap: 20,
icon: 'roundRect'
} : { show: false },
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: showLegend ? (title ? 90 : 60) : (title ? 60 : 30),
containLabel: true
},
xAxis: {
type: 'category',
data: parsedData.categories,
axisLine: { lineStyle: { color: axisLineColor } },
axisLabel: {
color: textColor,
fontSize: 12,
interval: 0,
rotate: parsedData.categories.length > 10 ? 30 : 0
},
axisTick: { show: false }
},
yAxis: dualYAxis ? [
{
type: 'value',
name: leftAxisName || '左轴',
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
splitLine: {
show: showGrid,
lineStyle: {
color: theme === 'dark' ? '#333' : '#f0f0f0',
type: 'dashed'
}
}
},
{
type: 'value',
name: rightAxisName || '右轴',
position: 'right',
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 4, 0, 0] },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
splitLine: { show: false }
}
] : {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
splitLine: {
show: showGrid,
lineStyle: {
color: theme === 'dark' ? '#333' : '#f0f0f0',
type: 'dashed'
}
}
},
series: series,
animation: false // 服务端渲染关闭动画
};
return option;
}
// ===== API: 生成图表图片 =====
app.post('/api/chart', (req, res) => {
try {
const params = req.body;
if (!params.data) {
return res.status(400).json({ error: '缺少 data 参数(CSV 格式数据)' });
}
const width = parseInt(params.width) || 800;
const height = parseInt(params.height) || 500;
const format = params.format || 'png';
const pixelRatio = parseInt(params.pixelRatio) || 2;
// 构建图表配置
const option = buildChartOption(params);
// 创建 canvas 并渲染
const canvas = createCanvas(width * pixelRatio, height * pixelRatio);
const chart = echarts.init(canvas, null, {
renderer: 'canvas',
width: width,
height: height,
devicePixelRatio: pixelRatio
});
chart.setOption(option);
// 输出图片
if (format === 'svg') {
// SVG 需要用 SVG 渲染器重新渲染
// node-canvas 不支持 SVG,返回 PNG 并提示
const buffer = canvas.toBuffer('image/png');
res.set({
'Content-Type': 'image/png',
'Content-Length': buffer.length,
'X-Chart-Format': 'png',
'X-Chart-Note': 'SVG format not supported in server-side rendering, returned PNG instead'
});
res.send(buffer);
} else {
const buffer = canvas.toBuffer('image/png');
res.set({
'Content-Type': 'image/png',
'Content-Length': buffer.length,
'X-Chart-Width': width,
'X-Chart-Height': height,
'X-Chart-Pixel-Ratio': pixelRatio
});
res.send(buffer);
}
chart.dispose();
} catch (err) {
console.error('Chart generation error:', err);
res.status(500).json({ error: '图表生成失败: ' + err.message });
}
});
// ===== API: 生成图表(GET 方式,方便 URL 直接调用) =====
app.get('/api/chart', (req, res) => {
try {
const params = {
data: req.query.data,
chartType: req.query.chartType || req.query.type || 'bar',
title: req.query.title || '',
theme: req.query.theme || 'default',
showLegend: req.query.showLegend !== 'false',
showGrid: req.query.showGrid !== 'false',
showLabel: req.query.showLabel === 'true',
stackMode: req.query.stackMode === 'true',
smoothLine: req.query.smoothLine !== 'false',
enableSplit: req.query.enableSplit === 'true',
splitIndex: parseInt(req.query.splitIndex) || 3,
leftLabel: req.query.leftLabel || '左侧',
rightLabel: req.query.rightLabel || '右侧',
splitStyle: req.query.splitStyle || 'solid',
dualYAxis: req.query.dualYAxis === 'true',
rightAxisSeries: req.query.rightAxisSeries ? req.query.rightAxisSeries.split(',') : null,
leftAxisName: req.query.leftAxisName || '',
rightAxisName: req.query.rightAxisName || '',
seriesTypes: req.query.seriesTypes ? req.query.seriesTypes.split(',') : null,
width: parseInt(req.query.width) || 800,
height: parseInt(req.query.height) || 500,
format: req.query.format || 'png',
pixelRatio: parseInt(req.query.pixelRatio) || 2
};
if (!params.data) {
return res.status(400).json({ error: '缺少 data 参数' });
}
// 解码 data(支持 URL 编码的换行符)
params.data = params.data.replace(/\\n/g, '\n');
const option = buildChartOption(params);
const width = params.width;
const height = params.height;
const pixelRatio = params.pixelRatio;
const canvas = createCanvas(width * pixelRatio, height * pixelRatio);
const chart = echarts.init(canvas, null, {
renderer: 'canvas',
width: width,
height: height,
devicePixelRatio: pixelRatio
});
chart.setOption(option);
const buffer = canvas.toBuffer('image/png');
res.set({
'Content-Type': 'image/png',
'Content-Length': buffer.length,
'X-Chart-Width': width,
'X-Chart-Height': height
});
res.send(buffer);
chart.dispose();
} catch (err) {
console.error('Chart generation error:', err);
res.status(500).json({ error: '图表生成失败: ' + err.message });
}
});
// ===== 表格数据解析(保留原始文本) =====
function parseTableData(rawText) {
const lines = rawText.trim().split('\n').filter(l => l.trim());
if (lines.length < 2) {
throw new Error('数据至少需要包含表头和一行数据');
}
// 自动检测分隔符
let delimiter = ',';
if (lines[0].includes('\t')) {
delimiter = '\t';
} else if (lines[0].split('|').length > lines[0].split(',').length) {
delimiter = '|';
}
const rows = lines.map(line => {
return line.split(delimiter).map(cell => cell.trim());
});
return {
headers: rows[0],
rows: rows.slice(1)
};
}
// ===== 表格主题配置 =====
const tableThemes = {
default: {
bgColor: '#ffffff',
headerBg: '#5470c6',
headerColor: '#ffffff',
cellColor: '#333333',
borderColor: '#e0e0e0',
stripeColor: '#f8f9fa',
titleColor: '#333333'
},
dark: {
bgColor: '#1a1a2e',
headerBg: '#4fc3f7',
headerColor: '#1a1a2e',
cellColor: '#e0e0e0',
borderColor: '#444444',
stripeColor: '#252540',
titleColor: '#e0e0e0'
},
macarons: {
bgColor: '#ffffff',
headerBg: '#2ec7c9',
headerColor: '#ffffff',
cellColor: '#333333',
borderColor: '#e8e8e8',
stripeColor: '#f0fafb',
titleColor: '#333333'
},
gradient: {
bgColor: '#ffffff',
headerBg: '#7f7fd5',
headerColor: '#ffffff',
cellColor: '#333333',
borderColor: '#e8e8e8',
stripeColor: '#f5f5ff',
titleColor: '#333333'
},
retro: {
bgColor: '#fefefe',
headerBg: '#95b9c7',
headerColor: '#ffffff',
cellColor: '#444444',
borderColor: '#d4a5a5',
stripeColor: '#f6e8c3',
titleColor: '#444444'
},
ocean: {
bgColor: '#f0f8ff',
headerBg: '#0077b6',
headerColor: '#ffffff',
cellColor: '#023e58',
borderColor: '#90e0ef',
stripeColor: '#caf0f8',
titleColor: '#023e58'
},
forest: {
bgColor: '#f5faf5',
headerBg: '#2d6a4f',
headerColor: '#ffffff',
cellColor: '#1b4332',
borderColor: '#95d5b2',
stripeColor: '#d8f3dc',
titleColor: '#1b4332'
},
sunset: {
bgColor: '#fff8f0',
headerBg: '#e85d04',
headerColor: '#ffffff',
cellColor: '#6a040f',
borderColor: '#ffba08',
stripeColor: '#fff3e0',
titleColor: '#6a040f'
},
lavender: {
bgColor: '#faf5ff',
headerBg: '#7c3aed',
headerColor: '#ffffff',
cellColor: '#4c1d95',
borderColor: '#c4b5fd',
stripeColor: '#ede9fe',
titleColor: '#4c1d95'
},
minimal: {
bgColor: '#ffffff',
headerBg: '#f8f9fa',
headerColor: '#212529',
cellColor: '#495057',
borderColor: '#dee2e6',
stripeColor: '#f8f9fa',
titleColor: '#212529'
},
cherry: {
bgColor: '#fff5f7',
headerBg: '#e91e63',
headerColor: '#ffffff',
cellColor: '#880e4f',
borderColor: '#f48fb1',
stripeColor: '#fce4ec',
titleColor: '#880e4f'
},
midnight: {
bgColor: '#1a1a2e',
headerBg: '#6c63ff',
headerColor: '#ffffff',
cellColor: '#e0e0e0',
borderColor: '#3d3d5c',
stripeColor: '#22223a',
titleColor: '#e0e0e0'
},
gold: {
bgColor: '#fffef5',
headerBg: '#b8860b',
headerColor: '#ffffff',
cellColor: '#5c4a00',
borderColor: '#daa520',
stripeColor: '#fdf6e3',
titleColor: '#5c4a00'
},
coral: {
bgColor: '#fff5f2',
headerBg: '#ff6f61',
headerColor: '#ffffff',
cellColor: '#5d2b1f',
borderColor: '#ffab91',
stripeColor: '#fff0ed',
titleColor: '#5d2b1f'
},
mint: {
bgColor: '#f0fff8',
headerBg: '#00bfa5',
headerColor: '#ffffff',
cellColor: '#004d40',
borderColor: '#80cbc4',
stripeColor: '#e0f2f1',
titleColor: '#004d40'
},
// 简约纯色风格
slate: {
bgColor: '#ffffff',
headerBg: '#475569',
headerColor: '#ffffff',
cellColor: '#334155',
borderColor: '#cbd5e1',
stripeColor: '#f8fafc',
titleColor: '#1e293b'
},
sky: {
bgColor: '#ffffff',
headerBg: '#0ea5e9',
headerColor: '#ffffff',
cellColor: '#0c4a6e',
borderColor: '#bae6fd',
stripeColor: '#f0f9ff',
titleColor: '#0c4a6e'
},
rose: {
bgColor: '#ffffff',
headerBg: '#e11d48',
headerColor: '#ffffff',
cellColor: '#881337',
borderColor: '#fecdd3',
stripeColor: '#fff1f2',
titleColor: '#881337'
},
amber: {
bgColor: '#ffffff',
headerBg: '#f59e0b',
headerColor: '#ffffff',
cellColor: '#78350f',
borderColor: '#fde68a',
stripeColor: '#fffbeb',
titleColor: '#78350f'
},
emerald: {
bgColor: '#ffffff',
headerBg: '#10b981',
headerColor: '#ffffff',
cellColor: '#064e3b',
borderColor: '#a7f3d0',
stripeColor: '#ecfdf5',
titleColor: '#064e3b'
},
indigo: {
bgColor: '#ffffff',
headerBg: '#6366f1',
headerColor: '#ffffff',
cellColor: '#312e81',
borderColor: '#c7d2fe',
stripeColor: '#eef2ff',
titleColor: '#312e81'
},
stone: {
bgColor: '#ffffff',
headerBg: '#78716c',
headerColor: '#ffffff',
cellColor: '#44403c',
borderColor: '#d6d3d1',
stripeColor: '#fafaf9',
titleColor: '#292524'
}
};
// ===== 测量文字宽度 =====
function measureTextWidth(ctx, text, fontSize) {
ctx.font = `${fontSize}px "Noto Sans CJK SC", sans-serif`;
const metrics = ctx.measureText(text);
return metrics.width;
}
// ===== 生成表格图片 =====
function generateTableImage(params) {
const {
data,
title = '',
theme = 'default',
fontSize = 14,
cellPadding = 12,
borderWidth = 1,
stripeRows = true,
pixelRatio = 2,
maxWidth = 1200,
borderStyle = 'all' // all, none, no-outer, no-horizontal, no-vertical, no-inner, no-inner-horizontal, no-inner-vertical, outer-only, header-only
} = params;
const tableData = parseTableData(data);
const { headers, rows } = tableData;
const themeConfig = tableThemes[theme] || tableThemes.default;
// 创建临时 canvas 用于测量文字
const tempCanvas = createCanvas(100, 100);
const tempCtx = tempCanvas.getContext('2d');
// 计算每列最大宽度
const colWidths = headers.map((header, colIdx) => {
let maxW = measureTextWidth(tempCtx, header, fontSize) + cellPadding * 2;
rows.forEach(row => {
if (row[colIdx]) {
const cellW = measureTextWidth(tempCtx, row[colIdx], fontSize) + cellPadding * 2;
if (cellW > maxW) maxW = cellW;
}
});
return Math.min(maxW, 300); // 单列最大宽度 300px
});
// 计算表格总宽度
const tableWidth = colWidths.reduce((sum, w) => sum + w, 0) + borderWidth * 2;
const finalWidth = Math.min(tableWidth, maxWidth);
// 如果超出最大宽度,按比例缩放列宽
if (tableWidth > maxWidth) {
const scale = maxWidth / tableWidth;
colWidths.forEach((_, i) => colWidths[i] *= scale);
}
// 计算行高
const headerHeight = fontSize * 2.5;
const rowHeight = fontSize * 2.2;
const titleHeight = title ? fontSize * 3 : 0;
const tableHeight = headerHeight + rows.length * rowHeight;
const finalHeight = tableHeight + titleHeight;
// 创建正式 canvas
const canvas = createCanvas(finalWidth * pixelRatio, finalHeight * pixelRatio);
const ctx = canvas.getContext('2d');
ctx.scale(pixelRatio, pixelRatio);
// 绘制背景
ctx.fillStyle = themeConfig.bgColor;
ctx.fillRect(0, 0, finalWidth, finalHeight);
let startY = 0;
// 绘制标题
if (title) {
ctx.fillStyle = themeConfig.titleColor;
ctx.font = `bold ${fontSize * 1.4}px "Noto Sans CJK SC", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(title, finalWidth / 2, titleHeight / 2);
startY = titleHeight;
}
// 绘制表头背景
ctx.fillStyle = themeConfig.headerBg;
ctx.fillRect(0, startY, finalWidth, headerHeight);
// 绘制表头文字
ctx.fillStyle = themeConfig.headerColor;
ctx.font = `bold ${fontSize}px "Noto Sans CJK SC", sans-serif`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
let x = 0;
headers.forEach((header, i) => {
ctx.fillText(header, x + cellPadding, startY + headerHeight / 2, colWidths[i] - cellPadding * 2);
x += colWidths[i];
});
// 绘制数据行
rows.forEach((row, rowIdx) => {
const y = startY + headerHeight + rowIdx * rowHeight;
// 斑马纹背景
if (stripeRows && rowIdx % 2 === 1) {
ctx.fillStyle = themeConfig.stripeColor;
ctx.fillRect(0, y, finalWidth, rowHeight);
}
// 绘制单元格文字
ctx.fillStyle = themeConfig.cellColor;
ctx.font = `${fontSize}px "Noto Sans CJK SC", sans-serif`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
let cellX = 0;
row.forEach((cell, colIdx) => {
if (cell && colWidths[colIdx]) {
ctx.fillText(cell, cellX + cellPadding, y + rowHeight / 2, colWidths[colIdx] - cellPadding * 2);
}
cellX += colWidths[colIdx];
});
});
// 绘制边框
ctx.strokeStyle = themeConfig.borderColor;
ctx.lineWidth = borderWidth;
// 边框样式控制
const drawOuter = !['none', 'no-outer', 'no-inner', 'no-inner-horizontal', 'no-inner-vertical', 'header-only'].includes(borderStyle);
const drawHorizontal = !['none', 'no-horizontal', 'no-inner', 'no-inner-horizontal', 'outer-only', 'header-only'].includes(borderStyle);
const drawVertical = !['none', 'no-vertical', 'no-inner', 'no-inner-vertical', 'outer-only'].includes(borderStyle);
const drawHeaderLine = !['none', 'no-horizontal', 'no-inner', 'no-inner-horizontal'].includes(borderStyle);
// 外边框
if (drawOuter) {
const offset = borderWidth / 2;
ctx.strokeRect(offset, startY + offset, finalWidth - borderWidth, tableHeight - borderWidth);
}
// 表头分隔线(横线)
if (drawHeaderLine) {
ctx.beginPath();
ctx.moveTo(0, startY + headerHeight);
ctx.lineTo(finalWidth, startY + headerHeight);
ctx.stroke();
}
// 数据行横线
if (drawHorizontal) {
rows.forEach((_, rowIdx) => {
const y = startY + headerHeight + (rowIdx + 1) * rowHeight;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(finalWidth, y);
ctx.stroke();
});
}
// 竖线
if (drawVertical) {
x = 0;
colWidths.forEach((width, i) => {
if (i > 0) {
ctx.beginPath();
ctx.moveTo(x, startY);
ctx.lineTo(x, startY + tableHeight);
ctx.stroke();
}
x += width;
});
}
return canvas;
}
// ===== API: 生成表格图片 (POST) =====
app.post('/api/table', (req, res) => {
try {
const params = req.body;
if (!params.data) {
return res.status(400).json({ error: '缺少 data 参数(CSV 格式数据)' });
}
const pixelRatio = parseInt(params.pixelRatio) || 2;
const canvas = generateTableImage(params);
const buffer = canvas.toBuffer('image/png');
res.set({
'Content-Type': 'image/png',
'Content-Length': buffer.length,
'X-Table-Format': 'png'
});
res.send(buffer);
} catch (err) {
console.error('Table generation error:', err);
res.status(500).json({ error: '表格生成失败: ' + err.message });
}
});
// ===== API: 生成表格图片 (GET) =====
app.get('/api/table', (req, res) => {
try {
const params = {
data: req.query.data,
title: req.query.title || '',
theme: req.query.theme || 'default',
fontSize: parseInt(req.query.fontSize) || 14,
cellPadding: parseInt(req.query.cellPadding) || 12,
borderWidth: parseInt(req.query.borderWidth) || 1,
stripeRows: req.query.stripeRows !== 'false',
pixelRatio: parseInt(req.query.pixelRatio) || 2,
maxWidth: parseInt(req.query.maxWidth) || 1200,
borderStyle: req.query.borderStyle || 'all'
};
if (!params.data) {
return res.status(400).json({ error: '缺少 data 参数' });
}
// 解码 data(支持 URL 编码的换行符)
params.data = params.data.replace(/\\n/g, '\n');
const canvas = generateTableImage(params);
const buffer = canvas.toBuffer('image/png');
res.set({
'Content-Type': 'image/png',
'Content-Length': buffer.length,
'X-Table-Format': 'png'
});
res.send(buffer);
} catch (err) {
console.error('Table generation error:', err);
res.status(500).json({ error: '表格生成失败: ' + err.message });
}
});
// ===== API: 健康检查 =====
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
service: 'data-chart-tool',
version: '1.14.0',
endpoints: {
'POST /api/chart': '生成图表图片(JSON body',
'GET /api/chart': '生成图表图片(URL 参数)',
'POST /api/table': '生成表格图片(JSON body',
'GET /api/table': '生成表格图片(URL 参数)',
'GET /api/health': '健康检查'
}
});
});
// ===== API: 文档/使用说明 =====
app.get('/api/docs', (req, res) => {
res.json({
name: '数据可视化图表生成器 API',
version: '1.14.0',
endpoints: [
{
method: 'POST',
path: '/api/chart',
description: '通过 JSON 请求体生成图表图片',
'Content-Type': 'application/json',
params: {
data: { type: 'string', required: true, description: 'CSV 格式数据(第一行表头,第一列横坐标)' },
chartType: { type: 'string', default: 'bar', options: ['bar', 'line', 'bar-line', 'pie', 'radar'], 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: '主题风格' },
showLegend: { type: 'boolean', default: true, description: '是否显示图例' },
showGrid: { type: 'boolean', default: true, description: '是否显示网格线' },
showLabel: { type: 'boolean', default: false, description: '是否显示数据标签' },
stackMode: { type: 'boolean', default: false, description: '是否堆叠模式' },
smoothLine: { type: 'boolean', default: true, description: '折线图是否平滑' },
enableSplit: { type: 'boolean', default: false, description: '是否启用区域分割' },
splitIndex: { type: 'number', default: 3, description: '分割位置索引' },
leftLabel: { type: 'string', default: '左侧', description: '左侧区域标签' },
rightLabel: { type: 'string', default: '右侧', description: '右侧区域标签' },
splitStyle: { type: 'string', default: 'solid', options: ['solid', 'dashed', 'dotted'], description: '分割线样式' },
width: { type: 'number', default: 800, description: '图片宽度(px)' },
height: { type: 'number', default: 500, description: '图片高度(px)' },
format: { type: 'string', default: 'png', options: ['png'], description: '输出格式' },
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' },
dualYAxis: { type: 'boolean', default: false, description: '是否启用双Y轴(左右量度不同)' },
rightAxisSeries: { type: 'array', default: null, description: '右轴系列名列表(如 ["利润"],指定哪些系列用右轴)' },
leftAxisName: { type: 'string', default: '', description: '左轴名称' },
rightAxisName: { type: 'string', default: '', description: '右轴名称' },
seriesTypes: { type: 'array', default: null, description: '每系列图表类型(如 ["bar","line"],按系列顺序对应,bar/line/auto' }
},
returns: 'image/png',
example: {
request: `curl -X POST http://localhost:16016/api/chart \\
-H "Content-Type: application/json" \\
-d '{
"data": "产品, Q1, Q2, Q3, Q4\\n手机, 1200, 1800, 2100, 2500\\n平板, 800, 950, 1100, 1300",
"chartType": "bar",
"title": "季度销售对比",
"theme": "default",
"width": 800,
"height": 500
}' -o chart.png`,
response: 'PNG 图片二进制流'
}
},
{
method: 'GET',
path: '/api/chart',
description: '通过 URL 参数生成图表图片(适合简单场景)',
params: {
data: { type: 'string', required: true, description: 'CSV 数据(换行用 \\n 分隔)' },
type: { type: 'string', default: 'bar', description: '图表类型' },
title: { type: 'string', description: '图表标题' },
theme: { type: 'string', default: 'default', description: '主题风格' },
width: { type: 'number', default: 800 },
height: { type: 'number', default: 500 }
},
returns: 'image/png',
example: {
request: `curl "http://localhost:16016/api/chart?data=产品,Q1,Q2\\n手机,100,200\\n平板,150,250&type=bar&title=测试" -o chart.png`
}
},
{
method: 'POST',
path: '/api/table',
description: '通过 JSON 请求体生成表格图片',
'Content-Type': 'application/json',
params: {
data: { type: 'string', required: true, description: 'CSV 格式数据(第一行表头)' },
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: '主题风格' },
fontSize: { type: 'number', default: 14, description: '字体大小' },
cellPadding: { type: 'number', default: 12, description: '单元格内边距' },
borderWidth: { type: 'number', default: 1, description: '边框宽度' },
stripeRows: { type: 'boolean', default: true, description: '是否斑马纹' },
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' },
maxWidth: { type: 'number', default: 1200, description: '最大宽度(px)' },
borderStyle: { type: 'string', default: 'all', options: ['all', 'none', 'no-outer', 'no-horizontal', 'no-vertical', 'no-inner', 'no-inner-horizontal', 'no-inner-vertical', 'outer-only', 'header-only'], description: '边框样式' }
},
returns: 'image/png',
example: {
request: `curl -X POST http://localhost:16016/api/table \\
-H "Content-Type: application/json" \\
-d '{
"data": "姓名, 部门, 职位, 薪资\\n张三, 技术部, 工程师, 15000\\n李四, 产品部, 产品经理, 18000\\n王五, 设计部, UI设计师, 16000",
"title": "员工信息表",
"theme": "default"
}' -o table.png`,
response: 'PNG 图片二进制流'
}
},
{
method: 'GET',
path: '/api/table',
description: '通过 URL 参数生成表格图片',
params: {
data: { type: 'string', required: true, description: 'CSV 数据(换行用 \\n 分隔)' },
title: { type: 'string', description: '表格标题' },
theme: { type: 'string', default: 'default', description: '主题风格' },
fontSize: { type: 'number', default: 14 },
stripeRows: { type: 'boolean', default: true }
},
returns: 'image/png',
example: {
request: `curl "http://localhost:16016/api/table?data=产品,价格,库存\\n手机,2999,100\\n平板,1999,50&title=产品列表" -o table.png`
}
},
{
method: 'POST',
path: '/api/combine',
description: '将多张图表合并到一张图片中(支持横排/竖排)',
'Content-Type': 'application/json',
params: {
charts: { type: 'array', required: true, description: '图表配置数组(N 张),每项同 /api/chart 参数;兼容 chart1 + chart2' },
direction: { type: 'string', default: 'horizontal', options: ['horizontal', 'vertical', 'grid'], description: '排布方式:horizontal 横排(单行)/ vertical 竖排(单列)/ grid 多行多列' },
cols: { type: 'number', default: 2, description: '多行多列时的每行列数(仅 grid 生效)' },
gap: { type: 'number', default: 24, description: '图间距(px)' },
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' },
background: { type: 'string', default: '#ffffff', description: '背景色' }
},
returns: 'image/png',
example: {
request: `curl -X POST http://localhost:16016/api/combine \\
-H "Content-Type: application/json" \\
-d '{
"chart1": {"data": "产品, Q1, Q2\\n手机, 1200, 1800\\n平板, 800, 950", "title": "2024年销售", "chartType": "bar"},
"chart2": {"data": "月份, 营收\\n1月, 500\\n2月, 680\\n3月, 820", "title": "营收趋势", "chartType": "line"},
"direction": "horizontal"
}' -o combine.png`,
response: 'PNG 图片二进制流'
}
}
]
});
});
// ===== API: 多图合并生成图片 =====
app.post('/api/combine', async (req, res) => {
try {
const body = req.body || {};
// 支持 charts 数组(N 张图),也兼容旧的 chart1 + chart2
let charts;
if (Array.isArray(body.charts) && body.charts.length) {
charts = body.charts;
} else if (body.chart1 && body.chart2) {
charts = [body.chart1, body.chart2];
} else {
return res.status(400).json({ error: '需要提供 charts 数组(或 chart1 + chart2' });
}
charts = charts.filter(c => c && c.data);
if (charts.length === 0) {
return res.status(400).json({ error: '至少需要一个有数据的图表(data 参数)' });
}
const direction = ['vertical', 'grid'].includes(body.direction) ? body.direction : 'horizontal';
const cols = Math.max(1, parseInt(body.cols) || 2);
const gap = parseInt(body.gap) || 24;
const pixelRatio = parseInt(body.pixelRatio) || 2;
const background = body.background || '#ffffff';
// 渲染单个子图(直接按目标尺寸渲染,避免二次缩放损失)
// 注意:必须先 toBuffer 再 chart.dispose()dispose 会清空 canvas 内容!
const renderSub = async (params, w, h) => {
const option = buildChartOption(params);
const c = createCanvas(w * pixelRatio, h * pixelRatio);
const chart = echarts.init(c, null, {
renderer: 'canvas',
width: w,
height: h,
devicePixelRatio: pixelRatio
});
chart.setOption(option);
const buf = c.toBuffer('image/png');
chart.dispose();
const img = await loadImage(buf);
return { img, w, h };
};
const subs = await Promise.all(charts.map(c =>
renderSub(c, parseInt(c.width) || 640, parseInt(c.height) || 420)
));
let W, H;
const canvas = createCanvas(1, 1);
const ctx = canvas.getContext('2d');
if (direction === 'grid') {
// 多行多列网格:所有子图统一 contain 到最大单元格,按列填充
const rows = Math.ceil(subs.length / cols);
const cellW = Math.max(...subs.map(s => s.w));
const cellH = Math.max(...subs.map(s => s.h));
W = cols * cellW + (cols - 1) * gap;
H = rows * cellH + (rows - 1) * gap;
canvas.width = W * pixelRatio;
canvas.height = H * pixelRatio;
ctx.fillStyle = background;
ctx.fillRect(0, 0, W * pixelRatio, H * pixelRatio);
subs.forEach((s, i) => {
const r = Math.floor(i / cols), c = i % cols;
const scale = Math.min(cellW / s.w, cellH / s.h);
const dw = Math.round(s.w * scale), dh = Math.round(s.h * scale);
const x = c * (cellW + gap) + Math.round((cellW - dw) / 2);
const y = r * (cellH + gap) + Math.round((cellH - dh) / 2);
ctx.drawImage(s.img, x * pixelRatio, y * pixelRatio, dw * pixelRatio, dh * pixelRatio);
});
} else {
let scaled;
if (direction === 'vertical') {
// 竖排(上下):等宽对齐
const tw = Math.max(...subs.map(s => s.w));
scaled = subs.map(s => ({ img: s.img, w: tw, h: Math.round(s.h * (tw / s.w)) }));
W = tw;
H = scaled.reduce((sum, s) => sum + s.h, 0) + gap * (scaled.length - 1);
} else {
// 横排(左右):等高对齐
const th = Math.max(...subs.map(s => s.h));
scaled = subs.map(s => ({ img: s.img, w: Math.round(s.w * (th / s.h)), h: th }));
W = scaled.reduce((sum, s) => sum + s.w, 0) + gap * (scaled.length - 1);
H = th;
}
canvas.width = W * pixelRatio;
canvas.height = H * pixelRatio;
ctx.fillStyle = background;
ctx.fillRect(0, 0, W * pixelRatio, H * pixelRatio);
if (direction === 'vertical') {
// 5 参数形式:drawImage(img, dx, dy, dw, dh),源整图缩放到目标矩形
let y = 0;
scaled.forEach(s => {
ctx.drawImage(s.img, 0, y * pixelRatio, s.w * pixelRatio, s.h * pixelRatio);
y += s.h + gap;
});
} else {
let x = 0;
scaled.forEach(s => {
ctx.drawImage(s.img, x * pixelRatio, 0, s.w * pixelRatio, s.h * pixelRatio);
x += s.w + gap;
});
}
}
const buffer = canvas.toBuffer('image/png');
res.set({
'Content-Type': 'image/png',
'Content-Length': buffer.length,
'X-Combine-Direction': direction,
'X-Combine-Charts': String(subs.length),
'X-Chart-Width': W,
'X-Chart-Height': H
});
res.send(buffer);
} catch (err) {
console.error('Combine error:', err);
res.status(500).json({ error: '多图合并失败: ' + err.message });
}
});
// ===== 启动服务 =====
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 数据可视化图表生成器已启动`);
console.log(`📊 Web UI: http://0.0.0.0:${PORT}`);
console.log(`📡 图表API: http://0.0.0.0:${PORT}/api/chart`);
console.log(`📋 表格API: http://0.0.0.0:${PORT}/api/table`);
console.log(`🖼️ 合并API: http://0.0.0.0:${PORT}/api/combine`);
console.log(`📖 文档: http://0.0.0.0:${PORT}/api/docs`);
console.log(`❤️ 健康: http://0.0.0.0:${PORT}/api/health`);
});