Files
data-chart-tool/server.js
T

483 lines
18 KiB
JavaScript
Raw Permalink 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 } = require('@napi-rs/canvas');
const echarts = require('echarts');
const path = require('path');
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']
};
// ===== 数据解析(与前端一致) =====
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
} = 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';
// 构建系列
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;
const seriesItem = {
name: name,
type: type,
data: [...dataArr],
itemStyle: { color: color },
emphasis: {
focus: 'series',
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(0,0,0,0.3)'
}
}
};
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: {
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',
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 });
}
});
// ===== API: 健康检查 =====
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
service: 'data-chart-tool',
version: '1.1.0',
endpoints: {
'POST /api/chart': '生成图表图片(JSON body',
'GET /api/chart': '生成图表图片(URL 参数)',
'GET /api/health': '健康检查'
}
});
});
// ===== API: 文档/使用说明 =====
app.get('/api/docs', (req, res) => {
res.json({
name: '数据可视化图表生成器 API',
version: '1.1.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'], description: '图表类型' },
title: { type: 'string', default: '', description: '图表标题' },
theme: { type: 'string', default: 'default', options: ['default', 'dark', 'macarons', 'gradient', 'retro'], 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: '像素倍率(清晰度)' }
},
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`
}
}
]
});
});
// ===== 启动服务 =====
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(`📖 文档: http://0.0.0.0:${PORT}/api/docs`);
console.log(`❤️ 健康: http://0.0.0.0:${PORT}/api/health`);
});