feat: 新增双图合并模式,支持横排/竖排将两张图合并为一张图

- 后端新增 POST /api/combine 接口(chart1/chart2 + direction/gap/pixelRatio/background)
- 前端新增 🖼️ 双图合并模式:两张图独立配置数据/标题/类型/主题
- 横排(左右等高三张图)或竖排(上下等宽),一键切换实时预览
- 修复 @napi-rs/canvas echarts 渲染需先 toBuffer 再 dispose 的关键问题
- 更新 README/API 文档
This commit is contained in:
2026-08-19 12:15:31 +08:00
parent 3107d56764
commit e62aac539b
6 changed files with 610 additions and 9 deletions
+110 -1
View File
@@ -1,6 +1,6 @@
const express = require('express');
const cors = require('cors');
const { createCanvas, registerFont } = require('@napi-rs/canvas');
const { createCanvas, registerFont, loadImage } = require('@napi-rs/canvas');
const echarts = require('echarts');
const path = require('path');
@@ -1000,17 +1000,126 @@ app.get('/api/docs', (req, res) => {
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: {
chart1: { type: 'object', required: true, description: '第一张图配置(同 /api/chart 参数)' },
chart2: { type: 'object', required: true, description: '第二张图配置(同 /api/chart 参数)' },
direction: { type: 'string', default: 'horizontal', options: ['horizontal', 'vertical'], description: '排列方向:horizontal 横排(左右)/ vertical 竖排(上下)' },
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 || {};
const chart1 = body.chart1 || {};
const chart2 = body.chart2 || {};
const direction = body.direction === 'vertical' ? 'vertical' : 'horizontal';
const gap = parseInt(body.gap) || 24;
const pixelRatio = parseInt(body.pixelRatio) || 2;
const background = body.background || '#ffffff';
if (!chart1.data || !chart2.data) {
return res.status(400).json({ error: 'chart1 和 chart2 都需要提供 data 参数(CSV 格式)' });
}
// 渲染单个子图(直接按目标尺寸渲染,避免二次缩放损失)
// 注意:必须先 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 a = await renderSub(chart1, parseInt(chart1.width) || 640, parseInt(chart1.height) || 420);
const b = await renderSub(chart2, parseInt(chart2.width) || 640, parseInt(chart2.height) || 420);
let W, H, aRect, bRect;
if (direction === 'vertical') {
// 竖排(上下):等宽对齐
const tw = Math.max(a.w, b.w);
aRect = { w: tw, h: Math.round(a.h * (tw / a.w)) };
bRect = { w: tw, h: Math.round(b.h * (tw / b.w)) };
W = tw;
H = aRect.h + bRect.h + gap;
} else {
// 横排(左右):等高对齐
const th = Math.max(a.h, b.h);
aRect = { w: Math.round(a.w * (th / a.h)), h: th };
bRect = { w: Math.round(b.w * (th / b.h)), h: th };
W = aRect.w + bRect.w + gap;
H = th;
}
const canvas = createCanvas(W * pixelRatio, H * pixelRatio);
const ctx = canvas.getContext('2d');
ctx.fillStyle = background;
ctx.fillRect(0, 0, W * pixelRatio, H * pixelRatio);
if (direction === 'vertical') {
// 5 参数形式:drawImage(img, dx, dy, dw, dh),源整图缩放到目标矩形
ctx.drawImage(a.img, 0, 0, aRect.w * pixelRatio, aRect.h * pixelRatio);
ctx.drawImage(b.img, 0, (aRect.h + gap) * pixelRatio, bRect.w * pixelRatio, bRect.h * pixelRatio);
} else {
ctx.drawImage(a.img, 0, 0, aRect.w * pixelRatio, aRect.h * pixelRatio);
ctx.drawImage(b.img, (aRect.w + gap) * pixelRatio, 0, bRect.w * pixelRatio, bRect.h * pixelRatio);
}
const buffer = canvas.toBuffer('image/png');
res.set({
'Content-Type': 'image/png',
'Content-Length': buffer.length,
'X-Combine-Direction': direction,
'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`);
});