feat: 多图合并支持多行多列网格排布 + 统一'多图合并'文案

- 合并模式新增'多行多列'排布(radio),可选每行列数(gridCols),
  子图按最大单元格 contain 居中排列,列数变化实时重排
- 后端 /api/combine 支持 direction=grid + cols 参数
- 页面所有'双图合并'文案统一改为'多图合并'
This commit is contained in:
2026-08-19 13:39:14 +08:00
parent e56c6cb79f
commit 6b5223daec
5 changed files with 130 additions and 68 deletions
+61 -34
View File
@@ -928,7 +928,7 @@ app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
service: 'data-chart-tool',
version: '1.13.0',
version: '1.13.1',
endpoints: {
'POST /api/chart': '生成图表图片(JSON body',
'GET /api/chart': '生成图表图片(URL 参数)',
@@ -943,7 +943,7 @@ app.get('/api/health', (req, res) => {
app.get('/api/docs', (req, res) => {
res.json({
name: '数据可视化图表生成器 API',
version: '1.13.0',
version: '1.13.1',
endpoints: [
{
method: 'POST',
@@ -1059,7 +1059,8 @@ app.get('/api/docs', (req, res) => {
'Content-Type': 'application/json',
params: {
charts: { type: 'array', required: true, description: '图表配置数组(N 张),每项同 /api/chart 参数;兼容 chart1 + chart2' },
direction: { type: 'string', default: 'horizontal', options: ['horizontal', 'vertical'], description: '排列方向horizontal 横排(左右/ vertical 竖排(上下)' },
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: '背景色' }
@@ -1098,7 +1099,8 @@ app.post('/api/combine', async (req, res) => {
return res.status(400).json({ error: '至少需要一个有数据的图表(data 参数)' });
}
const direction = body.direction === 'vertical' ? 'vertical' : 'horizontal';
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';
@@ -1125,39 +1127,64 @@ app.post('/api/combine', async (req, res) => {
renderSub(c, parseInt(c.width) || 640, parseInt(c.height) || 420)
));
let W, H, 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;
}
const canvas = createCanvas(W * pixelRatio, H * pixelRatio);
let W, H;
const canvas = createCanvas(1, 1);
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),源整图缩放到目标矩形
let y = 0;
scaled.forEach(s => {
ctx.drawImage(s.img, 0, y * pixelRatio, s.w * pixelRatio, s.h * pixelRatio);
y += s.h + gap;
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 x = 0;
scaled.forEach(s => {
ctx.drawImage(s.img, x * pixelRatio, 0, s.w * pixelRatio, s.h * pixelRatio);
x += s.w + gap;
});
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');
@@ -1165,7 +1192,7 @@ app.post('/api/combine', async (req, res) => {
'Content-Type': 'image/png',
'Content-Length': buffer.length,
'X-Combine-Direction': direction,
'X-Combine-Charts': String(scaled.length),
'X-Combine-Charts': String(subs.length),
'X-Chart-Width': W,
'X-Chart-Height': H
});