Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b5223daec |
@@ -159,7 +159,7 @@ curl "http://192.168.0.101:16016/api/chart?data=%E4%BA%A7%E5%93%81,Q1,Q2%0A%E6%8
|
|||||||
|
|
||||||
## 3. POST /api/combine(多图合并)
|
## 3. POST /api/combine(多图合并)
|
||||||
|
|
||||||
将**多张图表**合并到一张图片中(默认 2 张,可多张),支持**横排(左右并排)** 或 **竖排(上下堆叠)** 两种方向,返回 PNG 图片。
|
将**多张图表**合并到一张图片中(默认 2 张,可多张),支持**横排(单行)**、**竖排(单列)**、**多行多列网格**三种排布,返回 PNG 图片。
|
||||||
|
|
||||||
### 请求
|
### 请求
|
||||||
|
|
||||||
@@ -173,7 +173,8 @@ Content-Type: application/json
|
|||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `charts` | array | ✅ | 图表配置数组(N 张),每项同 `/api/chart` 参数:data/chartType/title/theme 等 |
|
| `charts` | array | ✅ | 图表配置数组(N 张),每项同 `/api/chart` 参数:data/chartType/title/theme 等 |
|
||||||
| `direction` | string | 否 | `horizontal` 横排(默认)/ `vertical` 竖排 |
|
| `direction` | string | 否 | `horizontal` 横排(默认)/ `vertical` 竖排 / `grid` 多行多列 |
|
||||||
|
| `cols` | number | 否 | 多行多列时的每行列数(默认 2,仅 `grid` 生效) |
|
||||||
| `gap` | number | 否 | 图间距(默认 24px) |
|
| `gap` | number | 否 | 图间距(默认 24px) |
|
||||||
| `pixelRatio` | number | 否 | 像素倍率,默认 2(越清晰文件越大) |
|
| `pixelRatio` | number | 否 | 像素倍率,默认 2(越清晰文件越大) |
|
||||||
| `background` | string | 否 | 背景色,默认 `#ffffff` |
|
| `background` | string | 否 | 背景色,默认 `#ffffff` |
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
### 🖼️ 多图合并
|
### 🖼️ 多图合并
|
||||||
- 将多张图表合并到一张图片中,适合对比/汇总场景,**默认2张,可一键不断添加**(支持删除)
|
- 将多张图表合并到一张图片中,适合对比/汇总场景,**默认2张,可一键不断添加**(支持删除)
|
||||||
- 支持**横排(左右并排)** 和 **竖排(上下堆叠)**,一键切换实时预览
|
- 支持**横排(单行)**、**竖排(单列)**、**多行多列网格**(可设每行列数),一键切换实时预览
|
||||||
- 每张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠
|
- 每张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠
|
||||||
- 支持导出 PNG
|
- 支持导出 PNG
|
||||||
|
|
||||||
|
|||||||
@@ -1016,7 +1016,7 @@ function quickSwitchTheme(theme) {
|
|||||||
document.getElementById('tableTheme').value = theme;
|
document.getElementById('tableTheme').value = theme;
|
||||||
updateTable();
|
updateTable();
|
||||||
}
|
}
|
||||||
// 双图合并模式:仅切换按钮高亮,不联动(两图各自独立主题)
|
// 多图合并模式:仅切换按钮高亮,不联动(各图独立主题)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 同步风格按钮状态(下拉框变更时) =====
|
// ===== 同步风格按钮状态(下拉框变更时) =====
|
||||||
@@ -1039,7 +1039,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
initThemeButtons();
|
initThemeButtons();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== 双图合并 =====
|
// ===== 多图合并 =====
|
||||||
let combinedCanvas = null;
|
let combinedCanvas = null;
|
||||||
|
|
||||||
// 构建单个子图的 echarts option(不依赖全局状态,双图模式专用)
|
// 构建单个子图的 echarts option(不依赖全局状态,双图模式专用)
|
||||||
@@ -1245,6 +1245,13 @@ function removeCombineChart(i) {
|
|||||||
generateCombine();
|
generateCombine();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 多行多列切换:显示/隐藏列数输入
|
||||||
|
function onCombineGridToggle() {
|
||||||
|
const grid = document.querySelector('input[name="combineDirection"]:checked').value === 'grid';
|
||||||
|
document.getElementById('gridColsGroup').style.display = grid ? 'block' : 'none';
|
||||||
|
generateCombine();
|
||||||
|
}
|
||||||
|
|
||||||
// 生成合并图(多图)
|
// 生成合并图(多图)
|
||||||
function generateCombine() {
|
function generateCombine() {
|
||||||
const validCharts = combineCharts.filter(c => c.data && c.data.trim());
|
const validCharts = combineCharts.filter(c => c.data && c.data.trim());
|
||||||
@@ -1284,34 +1291,55 @@ function generateCombine() {
|
|||||||
const m = measureCombineChart(c.data);
|
const m = measureCombineChart(c.data);
|
||||||
return renderOne(c, m.w, m.h);
|
return renderOne(c, m.w, m.h);
|
||||||
})).then(canvases => {
|
})).then(canvases => {
|
||||||
let W, H, scaled;
|
|
||||||
if (direction === 'vertical') {
|
|
||||||
// 竖排:等宽
|
|
||||||
const tw = Math.max(...canvases.map(c => c.width));
|
|
||||||
scaled = canvases.map(c => ({ c, w: tw, h: Math.round(c.height * tw / c.width) }));
|
|
||||||
W = tw;
|
|
||||||
H = scaled.reduce((s, x) => s + x.h, 0) + gap * (scaled.length - 1);
|
|
||||||
} else {
|
|
||||||
// 横排:等高
|
|
||||||
const th = Math.max(...canvases.map(c => c.height));
|
|
||||||
scaled = canvases.map(c => ({ c, w: Math.round(c.width * th / c.height), h: th }));
|
|
||||||
W = scaled.reduce((s, x) => s + x.w, 0) + gap * (scaled.length - 1);
|
|
||||||
H = th;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = W;
|
|
||||||
canvas.height = H;
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.fillStyle = '#ffffff';
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
|
|
||||||
if (direction === 'vertical') {
|
if (direction === 'grid') {
|
||||||
let y = 0;
|
// 多行多列网格:所有子图统一 contain 到最大单元格,按列填充
|
||||||
scaled.forEach(x => { ctx.drawImage(x.c, 0, y, x.w, x.h); y += x.h + gap; });
|
const cols = Math.max(1, parseInt(document.getElementById('gridCols').value) || 2);
|
||||||
|
const rows = Math.ceil(canvases.length / cols);
|
||||||
|
const cellW = Math.max(...canvases.map(c => c.width));
|
||||||
|
const cellH = Math.max(...canvases.map(c => c.height));
|
||||||
|
canvas.width = cols * cellW + (cols - 1) * gap;
|
||||||
|
canvas.height = rows * cellH + (rows - 1) * gap;
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
canvases.forEach((c, i) => {
|
||||||
|
const r = Math.floor(i / cols), col = i % cols;
|
||||||
|
const scale = Math.min(cellW / c.width, cellH / c.height);
|
||||||
|
const dw = Math.round(c.width * scale), dh = Math.round(c.height * scale);
|
||||||
|
const x = col * (cellW + gap) + Math.round((cellW - dw) / 2);
|
||||||
|
const y = r * (cellH + gap) + Math.round((cellH - dh) / 2);
|
||||||
|
ctx.drawImage(c, x, y, dw, dh);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
let x = 0;
|
let W, H, scaled;
|
||||||
scaled.forEach(s => { ctx.drawImage(s.c, x, 0, s.w, s.h); x += s.w + gap; });
|
if (direction === 'vertical') {
|
||||||
|
// 竖排:等宽
|
||||||
|
const tw = Math.max(...canvases.map(c => c.width));
|
||||||
|
scaled = canvases.map(c => ({ c, w: tw, h: Math.round(c.height * tw / c.width) }));
|
||||||
|
W = tw;
|
||||||
|
H = scaled.reduce((s, x) => s + x.h, 0) + gap * (scaled.length - 1);
|
||||||
|
} else {
|
||||||
|
// 横排:等高
|
||||||
|
const th = Math.max(...canvases.map(c => c.height));
|
||||||
|
scaled = canvases.map(c => ({ c, w: Math.round(c.width * th / c.height), h: th }));
|
||||||
|
W = scaled.reduce((s, x) => s + x.w, 0) + gap * (scaled.length - 1);
|
||||||
|
H = th;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = W;
|
||||||
|
canvas.height = H;
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
if (direction === 'vertical') {
|
||||||
|
let y = 0;
|
||||||
|
scaled.forEach(x => { ctx.drawImage(x.c, 0, y, x.w, x.h); y += x.h + gap; });
|
||||||
|
} else {
|
||||||
|
let x = 0;
|
||||||
|
scaled.forEach(s => { ctx.drawImage(s.c, x, 0, s.w, s.h); x += s.w + gap; });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
combinedCanvas = canvas;
|
combinedCanvas = canvas;
|
||||||
|
|||||||
+11
-5
@@ -23,7 +23,7 @@
|
|||||||
<div class="mode-switch">
|
<div class="mode-switch">
|
||||||
<button class="mode-btn active" id="btnChartMode" onclick="switchMode('chart')">📈 图表模式</button>
|
<button class="mode-btn active" id="btnChartMode" onclick="switchMode('chart')">📈 图表模式</button>
|
||||||
<button class="mode-btn" id="btnTableMode" onclick="switchMode('table')">📋 表格模式</button>
|
<button class="mode-btn" id="btnTableMode" onclick="switchMode('table')">📋 表格模式</button>
|
||||||
<button class="mode-btn" id="btnCombineMode" onclick="switchMode('combine')">🖼️ 双图合并</button>
|
<button class="mode-btn" id="btnCombineMode" onclick="switchMode('combine')">🖼️ 多图合并</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -272,18 +272,24 @@ C, 20, 30, 40</pre>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 双图合并配置区 -->
|
<!-- 多图合并配置区 -->
|
||||||
<div class="panel-section" id="combineConfigSection" style="display:none;">
|
<div class="panel-section" id="combineConfigSection" style="display:none;">
|
||||||
<h2>🖼️ 多图合并配置</h2>
|
<h2>🖼️ 多图合并配置</h2>
|
||||||
|
|
||||||
<div class="config-group">
|
<div class="config-group">
|
||||||
<label>排列方向</label>
|
<label>排列方式</label>
|
||||||
<div class="combine-direction">
|
<div class="combine-direction">
|
||||||
<label class="dir-option"><input type="radio" name="combineDirection" value="horizontal" checked onchange="generateCombine()"> ⇄ 横排(左右)</label>
|
<label class="dir-option"><input type="radio" name="combineDirection" value="horizontal" checked onchange="generateCombine()"> ⇄ 横排(单行)</label>
|
||||||
<label class="dir-option"><input type="radio" name="combineDirection" value="vertical" onchange="generateCombine()"> ⇅ 竖排(上下)</label>
|
<label class="dir-option"><input type="radio" name="combineDirection" value="vertical" onchange="generateCombine()"> ⇅ 竖排(单列)</label>
|
||||||
|
<label class="dir-option"><input type="radio" name="combineDirection" value="grid" onchange="onCombineGridToggle()"> ▦ 多行多列</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="config-group" id="gridColsGroup" style="display:none;">
|
||||||
|
<label>每行列数</label>
|
||||||
|
<input type="number" id="gridCols" value="2" min="1" max="6" oninput="generateCombine()">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="combineChartsContainer">
|
<div id="combineChartsContainer">
|
||||||
<!-- 图表卡片由 JS 动态渲染 -->
|
<!-- 图表卡片由 JS 动态渲染 -->
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -928,7 +928,7 @@ app.get('/api/health', (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
service: 'data-chart-tool',
|
service: 'data-chart-tool',
|
||||||
version: '1.13.0',
|
version: '1.13.1',
|
||||||
endpoints: {
|
endpoints: {
|
||||||
'POST /api/chart': '生成图表图片(JSON body)',
|
'POST /api/chart': '生成图表图片(JSON body)',
|
||||||
'GET /api/chart': '生成图表图片(URL 参数)',
|
'GET /api/chart': '生成图表图片(URL 参数)',
|
||||||
@@ -943,7 +943,7 @@ app.get('/api/health', (req, res) => {
|
|||||||
app.get('/api/docs', (req, res) => {
|
app.get('/api/docs', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
name: '数据可视化图表生成器 API',
|
name: '数据可视化图表生成器 API',
|
||||||
version: '1.13.0',
|
version: '1.13.1',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1059,7 +1059,8 @@ app.get('/api/docs', (req, res) => {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
params: {
|
params: {
|
||||||
charts: { type: 'array', required: true, description: '图表配置数组(N 张),每项同 /api/chart 参数;兼容 chart1 + chart2' },
|
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)' },
|
gap: { type: 'number', default: 24, description: '图间距(px)' },
|
||||||
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' },
|
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' },
|
||||||
background: { type: 'string', default: '#ffffff', 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 参数)' });
|
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 gap = parseInt(body.gap) || 24;
|
||||||
const pixelRatio = parseInt(body.pixelRatio) || 2;
|
const pixelRatio = parseInt(body.pixelRatio) || 2;
|
||||||
const background = body.background || '#ffffff';
|
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)
|
renderSub(c, parseInt(c.width) || 640, parseInt(c.height) || 420)
|
||||||
));
|
));
|
||||||
|
|
||||||
let W, H, scaled;
|
let W, H;
|
||||||
if (direction === 'vertical') {
|
const canvas = createCanvas(1, 1);
|
||||||
// 竖排(上下):等宽对齐
|
|
||||||
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);
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.fillStyle = background;
|
|
||||||
ctx.fillRect(0, 0, W * pixelRatio, H * pixelRatio);
|
|
||||||
|
|
||||||
if (direction === 'vertical') {
|
if (direction === 'grid') {
|
||||||
// 5 参数形式:drawImage(img, dx, dy, dw, dh),源整图缩放到目标矩形
|
// 多行多列网格:所有子图统一 contain 到最大单元格,按列填充
|
||||||
let y = 0;
|
const rows = Math.ceil(subs.length / cols);
|
||||||
scaled.forEach(s => {
|
const cellW = Math.max(...subs.map(s => s.w));
|
||||||
ctx.drawImage(s.img, 0, y * pixelRatio, s.w * pixelRatio, s.h * pixelRatio);
|
const cellH = Math.max(...subs.map(s => s.h));
|
||||||
y += s.h + gap;
|
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 {
|
} else {
|
||||||
let x = 0;
|
let scaled;
|
||||||
scaled.forEach(s => {
|
if (direction === 'vertical') {
|
||||||
ctx.drawImage(s.img, x * pixelRatio, 0, s.w * pixelRatio, s.h * pixelRatio);
|
// 竖排(上下):等宽对齐
|
||||||
x += s.w + gap;
|
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');
|
const buffer = canvas.toBuffer('image/png');
|
||||||
@@ -1165,7 +1192,7 @@ app.post('/api/combine', async (req, res) => {
|
|||||||
'Content-Type': 'image/png',
|
'Content-Type': 'image/png',
|
||||||
'Content-Length': buffer.length,
|
'Content-Length': buffer.length,
|
||||||
'X-Combine-Direction': direction,
|
'X-Combine-Direction': direction,
|
||||||
'X-Combine-Charts': String(scaled.length),
|
'X-Combine-Charts': String(subs.length),
|
||||||
'X-Chart-Width': W,
|
'X-Chart-Width': W,
|
||||||
'X-Chart-Height': H
|
'X-Chart-Height': H
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user