Files
data-chart-tool/server.js
T

1621 lines
63 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 fs = require('fs');
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']
};
// ===== 数据解析(rowsAsSeries=true 时“行=系列”:第一行是横坐标,每行一个系列) =====
function parseData(rawText, rowsAsSeries) {
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 = {};
if (rowsAsSeries) {
// 行=系列:第一列=系列名,表头除第一列外=横坐标
const names = [];
const nameSet = {};
rows.slice(1).forEach(r => {
const n = ((r && r[0]) || '').trim();
if (n && !nameSet[n]) { nameSet[n] = true; names.push(n); }
});
names.forEach(n => { seriesData[n] = []; });
for (let j = 1; j < headers.length; j++) {
categories.push(headers[j]);
for (let i = 1; i < rows.length; i++) {
const r = rows[i];
const name = ((r && r[0]) || '').trim();
if (!seriesData[name]) continue;
const val = parseFloat(r[j]);
seriesData[name].push(isNaN(val) ? 0 : val);
}
}
return {
categories,
seriesNames: names,
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,
seriesAxis = null
} = params;
const parsedData = parseData(data, params.rowsAsSeries);
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 按名称指定右轴,seriesAxis 按系列下标(0左 1右)
if (dualYAxis) {
let axisIdx = 0;
if (Array.isArray(seriesAxis) && seriesAxis[idx] !== undefined && seriesAxis[idx] !== null && seriesAxis[idx] !== '') {
axisIdx = Number(seriesAxis[idx]) === 1 ? 1 : 0;
} else {
const rightNames = Array.isArray(rightAxisSeries) ? rightAxisSeries : (rightAxisSeries ? [rightAxisSeries] : []);
axisIdx = rightNames.includes(name) ? 1 : 0;
}
seriesItem.yAxisIndex = axisIdx;
}
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,
seriesAxis: req.query.seriesAxis ? req.query.seriesAxis.split(',').map(Number) : null,
rowsAsSeries: req.query.rowsAsSeries === 'true',
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.17.0',
endpoints: {
'POST /api/chart': '生成图表图片(JSON body',
'GET /api/chart': '生成图表图片(URL 参数)',
'POST /api/table': '生成表格图片(JSON body',
'GET /api/table': '生成表格图片(URL 参数)',
'POST /api/favorites': '保存收藏(原图+配置)',
'GET /api/favorites': '收藏列表',
'GET /api/favorites/:id': '单个收藏详情(含配置)',
'GET /api/favorites/:id/image': '收藏原图',
'DELETE /api/favorites/:id': '删除收藏',
'GET /api/health': '健康检查'
}
});
});
// ===== API: 文档/使用说明 =====
app.get('/api/docs', (req, res) => {
res.json({
name: '数据可视化图表生成器 API',
version: '1.17.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' },
seriesAxis: { type: 'array', default: null, description: '每系列坐标轴(如 [0,1]0=左轴 1=右轴,配合 dualYAxis 使用;优先于 rightAxisSeries' },
rowsAsSeries: { type: 'boolean', default: false, description: '数据方向:false=列=系列(第一列是横坐标,每列一个系列);true=行=系列(第一行是横坐标,每行一个系列)' }
},
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 参数(含 seriesTypes/seriesAxis/dualYAxis 等);兼容 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: '背景色' },
bigTitle: { type: 'string', default: '', description: '整张图片的大标题(可空)' },
legendMode: { type: 'string', default: 'own', options: ['own', 'shared-top', 'shared-bottom'], description: '图例方式:own 每个小图各自图例 / shared-top 共用图例放顶部 / shared-bottom 共用图例放底部(共用时各子图隐藏自己的图例)' },
theme: { type: 'string', default: 'default', description: '大标题/共享图例文字颜色所属主题(深色用 dark)' }
},
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 图片二进制流'
}
},
{
method: 'POST',
path: '/api/favorites',
description: '保存收藏(原始大小原图 + 完整配置,用于重新编辑)',
'Content-Type': 'application/json',
params: {
mode: { type: 'string', required: true, options: ['chart', 'table', 'combine'], description: '收藏类型' },
title: { type: 'string', default: '', description: '收藏标题' },
config: { type: 'object', required: true, description: '完整配置(数据/图表类型/主题/系列颜色顺序/轴/分割等)' },
image: { type: 'string', description: '原始大小图片 dataURL(可选,未传则服务端按配置生成)' }
},
returns: 'json { ok, id, title, width, height }'
},
{
method: 'GET',
path: '/api/favorites',
description: '收藏列表(按时间倒序)',
returns: 'json { ok, favorites: [{id, mode, title, createdAt, width, height}] }'
},
{
method: 'GET',
path: '/api/favorites/:id',
description: '单个收藏详情(含完整 config,供编辑页加载)'
},
{
method: 'GET',
path: '/api/favorites/:id/image',
description: '收藏原图 PNG?download=1 强制下载)'
},
{
method: 'DELETE',
path: '/api/favorites/:id',
description: '删除收藏'
}
]
});
});
// ===== 多图合并:收集共享图例条目(按名称去重) =====
function combineLegendItems(charts) {
const items = [];
const seen = {};
charts.forEach(c => {
if (!c || !c.data) return;
let p;
try { p = parseData(c.data, c.rowsAsSeries); } catch (e) { return; }
if (!p || !p.seriesNames) return;
const palette = colorPalettes[c.theme] || colorPalettes.default;
const custom = Array.isArray(c.seriesColors) ? c.seriesColors : null;
p.seriesNames.forEach((name, idx) => {
if (!seen[name]) {
seen[name] = true;
items.push({ name, color: custom ? (custom[idx] || palette[idx % palette.length]) : palette[idx % palette.length] });
}
});
});
return items;
}
// ===== 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';
const bigTitle = String(body.bigTitle || '');
const legendMode = ['shared-top', 'shared-bottom'].includes(body.legendMode) ? body.legendMode : 'own';
const sharedLegend = legendMode !== 'own';
const theme = body.theme || 'default';
const textColor = theme === 'dark' ? '#e0e0e0' : '#333333';
// 渲染单个子图(共享图例时隐藏各子图自己的图例;直接按目标尺寸渲染,避免二次缩放损失)
// 注意:必须先 toBuffer 再 chart.dispose()dispose 会清空 canvas 内容!
const renderSub = async (params, w, h) => {
const p = { ...params };
if (sharedLegend) p.showLegend = false;
const option = buildChartOption(p);
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, cells = [];
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;
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);
cells.push({
img: s.img,
x: c * (cellW + gap) + Math.round((cellW - dw) / 2),
y: r * (cellH + gap) + Math.round((cellH - dh) / 2),
w: dw, h: dh
});
});
} else if (direction === 'vertical') {
// 竖排(上下):等宽对齐
const tw = Math.max(...subs.map(s => s.w));
let y = 0;
cells = subs.map(s => {
const h = Math.round(s.h * (tw / s.w));
const cell = { img: s.img, x: 0, y, w: tw, h };
y += h + gap;
return cell;
});
W = tw;
H = y - gap;
} else {
// 横排(左右):等高对齐
const th = Math.max(...subs.map(s => s.h));
let x = 0;
cells = subs.map(s => {
const w = Math.round(s.w * (th / s.h));
const cell = { img: s.img, x, y: 0, w, h: th };
x += w + gap;
return cell;
});
W = x - gap;
H = th;
}
// 共享图例:按宽度换行计算
const legendFont = 13, swatch = 14, sw = 5, ig = 18, lineH = legendFont + 10;
let legendRows = [], legendH = 0;
if (sharedLegend) {
const items = combineLegendItems(charts);
const tmp = createCanvas(10, 10);
const tctx = tmp.getContext('2d');
tctx.font = `${legendFont}px "Noto Sans CJK SC", sans-serif`;
const maxW = W - 20;
let cur = [], curW = 0;
items.forEach(it => {
const itemW = swatch + sw + tctx.measureText(it.name).width;
if (cur.length && curW + ig + itemW > maxW) { legendRows.push(cur); cur = []; curW = 0; }
cur.push(it);
curW += itemW + (cur.length > 1 ? ig : 0);
});
if (cur.length) legendRows.push(cur);
legendH = legendRows.length * lineH + 8;
}
// 大标题区高度
const titleFont = 34;
const titleH = bigTitle ? titleFont + 26 : 0;
const topOffset = titleH + (legendMode === 'shared-top' ? legendH + 12 : 0);
const bottomOffset = legendMode === 'shared-bottom' ? legendH + 12 : 0;
const totalH = H + topOffset + bottomOffset;
// 创建最终画布(统一 scale,全部用逻辑坐标绘制)
const canvas = createCanvas(W * pixelRatio, totalH * pixelRatio);
const ctx = canvas.getContext('2d');
ctx.scale(pixelRatio, pixelRatio);
// 背景
ctx.fillStyle = background;
ctx.fillRect(0, 0, W, totalH);
// 大标题
if (bigTitle) {
ctx.font = `bold ${titleFont}px "Noto Sans CJK SC", sans-serif`;
ctx.fillStyle = textColor;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(bigTitle, W / 2, (titleH - 26) / 2 + 2);
}
// 绘制图例行(topY 为图例区顶部逻辑坐标)
const drawLegendBlock = (topY) => {
legendRows.forEach((row, ri) => {
ctx.font = `${legendFont}px "Noto Sans CJK SC", sans-serif`;
let totalW = 0;
row.forEach(it => { totalW += swatch + sw + ctx.measureText(it.name).width + ig; });
totalW -= ig;
let cx = (W - totalW) / 2;
row.forEach(it => {
ctx.fillStyle = it.color;
ctx.fillRect(cx, topY + ri * lineH + (legendFont - swatch) / 2 + 2, swatch, swatch);
ctx.fillStyle = textColor;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.font = `${legendFont}px "Noto Sans CJK SC", sans-serif`;
ctx.fillText(it.name, cx + swatch + sw, topY + ri * lineH + legendFont / 2 + 2);
cx += swatch + sw + ctx.measureText(it.name).width + ig;
});
});
};
// 顶部共享图例
if (sharedLegend && legendMode === 'shared-top') {
drawLegendBlock(titleH + 4);
}
// 子图(整体下移 topOffset
cells.forEach(cell => {
ctx.drawImage(cell.img, cell.x, cell.y + topOffset, cell.w, cell.h);
});
// 底部共享图例
if (sharedLegend && legendMode === 'shared-bottom') {
drawLegendBlock(topOffset + H + 8);
}
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 });
}
});
// ===== 收藏功能 =====
const FAV_DIR = path.join(__dirname, 'data', 'favorites');
const FAV_INDEX = path.join(FAV_DIR, 'index.json');
function ensureFavDir() {
if (!fs.existsSync(FAV_DIR)) fs.mkdirSync(FAV_DIR, { recursive: true });
}
function loadFavIndex() {
ensureFavDir();
try {
if (fs.existsSync(FAV_INDEX)) {
const arr = JSON.parse(fs.readFileSync(FAV_INDEX, 'utf8'));
return Array.isArray(arr) ? arr : [];
}
} catch (e) {
console.warn('收藏索引读取失败:', e.message);
}
return [];
}
function saveFavIndex(index) {
ensureFavDir();
fs.writeFileSync(FAV_INDEX, JSON.stringify(index, null, 2), 'utf8');
}
function genFavId() {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}_${Math.random().toString(36).slice(2, 6)}`;
}
function dataUrlToBuffer(dataUrl) {
if (!dataUrl) return null;
const m = /^data:image\/(png|jpeg|webp);base64,(.+)$/.exec(dataUrl);
if (!m) return null;
return Buffer.from(m[2], 'base64');
}
function getPngSize(buf) {
// PNG 签名 8 字节后 IHDR 块:length(4) + type(4) + width(4) + height(4)
if (buf && buf.length >= 24 && buf[0] === 0x89 && buf[1] === 0x50) {
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
}
return { w: 0, h: 0 };
}
// 服务端兜底渲染(前端未传图时按原始大小生成)
function renderFavoriteImage(mode, config) {
if (mode === 'chart') {
const width = parseInt(config.width) || 800;
const height = parseInt(config.height) || 500;
const pixelRatio = parseInt(config.pixelRatio) || 2;
const option = buildChartOption(config);
const canvas = createCanvas(width * pixelRatio, height * pixelRatio);
const chart = echarts.init(canvas, null, {
renderer: 'canvas', width, height, devicePixelRatio: pixelRatio
});
chart.setOption(option);
return canvas;
}
if (mode === 'table') {
return generateTableImage(config);
}
throw new Error('combine 模式需由前端提供原图');
}
// 保存收藏(前端传原始大小原图 + 完整配置,便于重新编辑)
app.post('/api/favorites', (req, res) => {
try {
const { mode = 'chart', title = '', config = {}, image } = req.body || {};
if (!['chart', 'table', 'combine'].includes(mode)) {
return res.status(400).json({ error: 'mode 必须是 chart / table / combine' });
}
ensureFavDir();
const id = genFavId();
const createdAt = new Date().toISOString();
let imagePath = null;
let width = 0, height = 0;
// 1) 优先保存前端回传的原图(原始大小)
const buf = dataUrlToBuffer(image);
if (buf) {
imagePath = path.join(FAV_DIR, id + '.png');
fs.writeFileSync(imagePath, buf);
} else {
// 2) 兜底:服务端按原始大小渲染
try {
const canvas = renderFavoriteImage(mode, config);
imagePath = path.join(FAV_DIR, id + '.png');
fs.writeFileSync(imagePath, canvas.toBuffer('image/png'));
} catch (e) {
console.warn('服务端渲染收藏图失败:', e.message);
}
}
if (imagePath && fs.existsSync(imagePath)) {
try {
const size = getPngSize(fs.readFileSync(imagePath));
width = size.w; height = size.h;
} catch (e) {}
}
const fav = { id, mode, title, createdAt, width, height, config };
const index = loadFavIndex();
index.unshift(fav);
saveFavIndex(index);
res.json({ ok: true, id, title, width, height, message: '收藏成功' });
} catch (err) {
console.error('Save favorite error:', err);
res.status(500).json({ error: '收藏失败: ' + err.message });
}
});
// 收藏列表
app.get('/api/favorites', (req, res) => {
res.json({ ok: true, favorites: loadFavIndex() });
});
// 单个收藏(含配置,用于编辑)
app.get('/api/favorites/:id', (req, res) => {
const id = req.params.id;
if (!/^[A-Za-z0-9_]+$/.test(id)) return res.status(400).json({ error: '非法 id' });
const fav = loadFavIndex().find(f => f.id === id);
if (!fav) return res.status(404).json({ error: '收藏不存在' });
res.json({ ok: true, favorite: fav });
});
// 收藏原图
app.get('/api/favorites/:id/image', (req, res) => {
const id = req.params.id;
if (!/^[A-Za-z0-9_]+$/.test(id)) return res.status(400).json({ error: '非法 id' });
const imgPath = path.join(FAV_DIR, id + '.png');
if (!fs.existsSync(imgPath)) return res.status(404).json({ error: '图片不存在' });
const buf = fs.readFileSync(imgPath);
res.set({
'Content-Type': 'image/png',
'Content-Length': buf.length,
'Cache-Control': 'public, max-age=86400'
});
if (req.query.download === '1') {
res.set('Content-Disposition', `attachment; filename="favorite_${id}.png"`);
}
res.send(buf);
});
// 删除收藏
app.delete('/api/favorites/:id', (req, res) => {
const id = req.params.id;
if (!/^[A-Za-z0-9_]+$/.test(id)) return res.status(400).json({ error: '非法 id' });
const index = loadFavIndex();
const next = index.filter(f => f.id !== id);
if (next.length === index.length) return res.status(404).json({ error: '收藏不存在' });
saveFavIndex(next);
try {
const p = path.join(FAV_DIR, id + '.png');
if (fs.existsSync(p)) fs.unlinkSync(p);
} catch (e) {}
res.json({ ok: true, 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(`⭐ 收藏API: http://0.0.0.0:${PORT}/api/favorites`);
console.log(`📖 文档: http://0.0.0.0:${PORT}/api/docs`);
console.log(`❤️ 健康: http://0.0.0.0:${PORT}/api/health`);
});