feat: 双图合并升级为多图合并 + 数据输入框加宽
- 合并模式默认2张图,新增'+添加图表'按钮可不断添加,每张图可独立删除 - 每张图卡片:标题/类型/主题/显示选项/数据,动态渲染(combineCharts 数组驱动) - 合并逻辑支持 N 张图:横排等高依次排列、竖排等宽依次堆叠 - 后端 /api/combine 支持 charts 数组(N 张),兼容 chart1+chart2 - 修复合并图数据输入框过窄:.config-group textarea 统一 width:100%(与设置区一致)
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
|------|------|------|
|
||||
| POST | `/api/chart` | 生成图表图片(JSON body,推荐) |
|
||||
| GET | `/api/chart` | 生成图表图片(URL 参数) |
|
||||
| POST | `/api/combine` | 双图合并(横排/竖排)生成一张图 |
|
||||
| POST | `/api/combine` | 多图合并(横排/竖排)生成一张图 |
|
||||
| GET | `/api/health` | 健康检查 |
|
||||
| GET | `/api/docs` | 返回本文档(JSON) |
|
||||
|
||||
@@ -157,9 +157,9 @@ 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(多图合并)
|
||||
|
||||
将两张图表合并到一张图片中,支持**横排(左右并排)** 或 **竖排(上下堆叠)** 两种方向,返回 PNG 图片。
|
||||
将**多张图表**合并到一张图片中(默认 2 张,可多张),支持**横排(左右并排)** 或 **竖排(上下堆叠)** 两种方向,返回 PNG 图片。
|
||||
|
||||
### 请求
|
||||
|
||||
@@ -172,22 +172,31 @@ Content-Type: application/json
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `chart1` | object | ✅ | 第一张图配置(同 `/api/chart` 参数:data/chartType/title/theme 等) |
|
||||
| `chart2` | object | ✅ | 第二张图配置(同 `/api/chart` 参数) |
|
||||
| `charts` | array | ✅ | 图表配置数组(N 张),每项同 `/api/chart` 参数:data/chartType/title/theme 等 |
|
||||
| `direction` | string | 否 | `horizontal` 横排(默认)/ `vertical` 竖排 |
|
||||
| `gap` | number | 否 | 两图间距(默认 24px) |
|
||||
| `gap` | number | 否 | 图间距(默认 24px) |
|
||||
| `pixelRatio` | number | 否 | 像素倍率,默认 2(越清晰文件越大) |
|
||||
| `background` | string | 否 | 背景色,默认 `#ffffff` |
|
||||
|
||||
> 兼容旧参数:`chart1` + `chart2` 仍可传(等价于 `charts: [chart1, chart2]`)。
|
||||
|
||||
每个子图配置支持:`data`(CSV)、`chartType`(bar/line/bar-line)、`title`、`theme`、`showLegend`、`showGrid`、`showLabel`、`stackMode`、`smoothLine`、`width`、`height`。
|
||||
|
||||
### curl 示例
|
||||
|
||||
**横排(左右并排):**
|
||||
**横排三张图(左右并排):**
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:16016/api/combine \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"charts": [
|
||||
{"data": "产品, Q1, Q2\n手机, 1200, 1800\n平板, 800, 950", "title": "销售", "chartType": "bar"},
|
||||
{"data": "月份, 营收\n1月, 500\n2月, 680\n3月, 820", "title": "趋势", "chartType": "line"},
|
||||
{"data": "地区, 销量\n华东, 300\n华南, 450", "title": "地区销量", "chartType": "bar"}
|
||||
],
|
||||
"direction": "horizontal"
|
||||
}' -o combine.png
|
||||
``` -d '{
|
||||
"chart1": {
|
||||
"data": "产品, Q1, Q2\n手机, 1200, 1800\n平板, 800, 950",
|
||||
"title": "2024年销售",
|
||||
|
||||
@@ -30,10 +30,10 @@
|
||||
- 可自定义分割线样式(实线/虚线/点线)
|
||||
- 自动标注左右区域标签
|
||||
|
||||
### 🖼️ 双图合并
|
||||
- 将两张图表合并到一张图片中,适合对比/汇总场景
|
||||
### 🖼️ 多图合并
|
||||
- 将多张图表合并到一张图片中,适合对比/汇总场景,**默认2张,可一键不断添加**(支持删除)
|
||||
- 支持**横排(左右并排)** 和 **竖排(上下堆叠)**,一键切换实时预览
|
||||
- 两张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠
|
||||
- 每张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠
|
||||
- 支持导出 PNG
|
||||
|
||||
### 📡 API 接口
|
||||
@@ -41,7 +41,7 @@
|
||||
- **GET /api/chart** - URL 参数生成图表(简单场景)
|
||||
- **POST /api/table** - JSON 请求体生成表格图片
|
||||
- **GET /api/table** - URL 参数生成表格图片
|
||||
- **POST /api/combine** - 双图合并(横排/竖排)生成一张图片
|
||||
- **POST /api/combine** - 多图合并(横排/竖排)生成一张图片
|
||||
- 返回 PNG 图片,支持自定义分辨率和像素倍率
|
||||
|
||||
### 📥 导出功能
|
||||
|
||||
@@ -61,12 +61,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('dataInput').value = sampleDataSets[0].data;
|
||||
generateChart();
|
||||
|
||||
// 预填双图合并示例(不提前生成,切到双图模式时由 switchMode 触发)
|
||||
document.getElementById('combineData1').value = sampleDataSets[0].data;
|
||||
document.getElementById('combineData2').value = sampleDataSets[1].data;
|
||||
document.getElementById('combineTitle1').value = '季度销售对比';
|
||||
document.getElementById('combineType2').value = 'line';
|
||||
document.getElementById('combineTitle2').value = '年度增长趋势';
|
||||
// 初始化多图合并(预填示例,不提前生成,切到该模式时由 switchMode 触发)
|
||||
initCombineCharts();
|
||||
|
||||
// 初始化导出设置
|
||||
renderHistory();
|
||||
@@ -1164,30 +1160,106 @@ function measureCombineChart(dataText) {
|
||||
return { w, h: 400 };
|
||||
}
|
||||
|
||||
// 生成合并图
|
||||
// ===== 多图合并管理 =====
|
||||
let combineCharts = [];
|
||||
|
||||
function defaultCombineChart() {
|
||||
return { title: '', type: 'bar', theme: 'default', legend: true, grid: true, label: false, stack: false, data: '' };
|
||||
}
|
||||
|
||||
function initCombineCharts() {
|
||||
combineCharts = [defaultCombineChart(), defaultCombineChart()];
|
||||
combineCharts[0].data = sampleDataSets[0].data;
|
||||
combineCharts[0].title = '季度销售对比';
|
||||
combineCharts[1].data = sampleDataSets[1].data;
|
||||
combineCharts[1].type = 'line';
|
||||
combineCharts[1].title = '年度增长趋势';
|
||||
renderCombineCharts();
|
||||
}
|
||||
|
||||
const COMBINE_TYPES = [['bar', '柱状图'], ['line', '折线图'], ['bar-line', '柱状图+折线图混合']];
|
||||
const COMBINE_THEMES = [['default', '默认'], ['dark', '深色'], ['macarons', '马卡龙'], ['gradient', '渐变'], ['retro', '复古'], ['ocean', '海洋'], ['forest', '森林'], ['sunset', '日落'], ['lavender', '薰衣草'], ['minimal', '极简'], ['cherry', '樱花'], ['midnight', '午夜'], ['gold', '金色'], ['coral', '珊瑚'], ['mint', '薄荷'], ['slate', '石板灰'], ['sky', '天空蓝'], ['rose', '玫瑰红'], ['amber', '琥珀黄'], ['emerald', '翡翠绿'], ['indigo', '靛蓝色'], ['stone', '石灰白']];
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function renderCombineCharts() {
|
||||
const container = document.getElementById('combineChartsContainer');
|
||||
if (!container) return;
|
||||
container.innerHTML = combineCharts.map((c, i) => `
|
||||
<div class="combine-chart-card" id="combineCard${i}">
|
||||
<div class="combine-card-head">
|
||||
<h3>📈 图${i + 1}</h3>
|
||||
<button class="btn btn-remove" onclick="removeCombineChart(${i})" title="删除此图">✕</button>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>标题</label>
|
||||
<input type="text" value="${escHtml(c.title)}" placeholder="图${i + 1}标题" oninput="updateCombineChart(${i},'title',this.value)">
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>图表类型</label>
|
||||
<select onchange="updateCombineChart(${i},'type',this.value)">
|
||||
${COMBINE_TYPES.map(([v, l]) => `<option value="${v}" ${c.type === v ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>主题风格</label>
|
||||
<select onchange="updateCombineChart(${i},'theme',this.value)">
|
||||
${COMBINE_THEMES.map(([v, l]) => `<option value="${v}" ${c.theme === v ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>显示选项</label>
|
||||
<div class="checkbox-group">
|
||||
<label><input type="checkbox" ${c.legend ? 'checked' : ''} onchange="updateCombineChart(${i},'legend',this.checked)"> 图例</label>
|
||||
<label><input type="checkbox" ${c.grid ? 'checked' : ''} onchange="updateCombineChart(${i},'grid',this.checked)"> 网格线</label>
|
||||
<label><input type="checkbox" ${c.label ? 'checked' : ''} onchange="updateCombineChart(${i},'label',this.checked)"> 数据标签</label>
|
||||
<label><input type="checkbox" ${c.stack ? 'checked' : ''} onchange="updateCombineChart(${i},'stack',this.checked)"> 堆叠</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>数据</label>
|
||||
<textarea rows="5" placeholder="图${i + 1}的CSV数据..." oninput="updateCombineChart(${i},'data',this.value)">${escHtml(c.data)}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function updateCombineChart(i, field, value) {
|
||||
if (!combineCharts[i]) return;
|
||||
combineCharts[i][field] = value;
|
||||
generateCombine();
|
||||
}
|
||||
|
||||
function addCombineChart() {
|
||||
combineCharts.push(defaultCombineChart());
|
||||
renderCombineCharts();
|
||||
generateCombine();
|
||||
}
|
||||
|
||||
function removeCombineChart(i) {
|
||||
if (combineCharts.length <= 1) { alert('至少保留一张图'); return; }
|
||||
combineCharts.splice(i, 1);
|
||||
renderCombineCharts();
|
||||
generateCombine();
|
||||
}
|
||||
|
||||
// 生成合并图(多图)
|
||||
function generateCombine() {
|
||||
const d1 = document.getElementById('combineData1').value.trim();
|
||||
const d2 = document.getElementById('combineData2').value.trim();
|
||||
if (!d1 || !d2) return;
|
||||
const validCharts = combineCharts.filter(c => c.data && c.data.trim());
|
||||
if (validCharts.length === 0) return;
|
||||
|
||||
const direction = document.querySelector('input[name="combineDirection"]:checked').value;
|
||||
const gap = 24;
|
||||
|
||||
const readCfg = (n) => ({
|
||||
title: document.getElementById('combineTitle' + n).value,
|
||||
chartType: document.getElementById('combineType' + n).value,
|
||||
theme: document.getElementById('combineTheme' + n).value,
|
||||
showLegend: document.getElementById('combineLegend' + n).checked,
|
||||
showGrid: document.getElementById('combineGrid' + n).checked,
|
||||
showLabel: document.getElementById('combineLabel' + n).checked,
|
||||
stackMode: document.getElementById('combineStack' + n).checked
|
||||
});
|
||||
|
||||
const chartArea = document.getElementById('chartArea');
|
||||
chartArea.innerHTML = '<div class="placeholder"><p>⏳</p><p>正在合并...</p></div>';
|
||||
|
||||
const renderOne = (dataText, cfg, w, h) => new Promise((resolve, reject) => {
|
||||
const option = buildCombineChartOption(dataText, cfg);
|
||||
const renderOne = (c, w, h) => new Promise((resolve, reject) => {
|
||||
const option = buildCombineChartOption(c.data, {
|
||||
title: c.title, chartType: c.type, theme: c.theme,
|
||||
showLegend: c.legend, showGrid: c.grid, showLabel: c.label, stackMode: c.stack
|
||||
});
|
||||
if (!option) { reject(new Error('数据格式错误')); return; }
|
||||
const holder = document.createElement('div');
|
||||
holder.style.cssText = `position:absolute;left:-9999px;top:0;width:${w}px;height:${h}px;`;
|
||||
@@ -1197,9 +1269,7 @@ function generateCombine() {
|
||||
setTimeout(() => {
|
||||
const srcCvs = holder.querySelector('canvas');
|
||||
if (!srcCvs) { reject(new Error('子图渲染失败')); return; }
|
||||
// 关键:先把内容复制到独立 canvas 再 dispose。
|
||||
// 直接 resolve 原 canvas 的话,Promise 微任务在 chart.dispose() 之后才执行,
|
||||
// 此时 canvas 内容已被 echarts 清空,合并出来就是白图。
|
||||
// 关键:先把内容复制到独立 canvas 再 dispose,避免 dispose 清空内容
|
||||
const copy = document.createElement('canvas');
|
||||
copy.width = srcCvs.width;
|
||||
copy.height = srcCvs.height;
|
||||
@@ -1210,26 +1280,23 @@ function generateCombine() {
|
||||
}, 80);
|
||||
});
|
||||
|
||||
const m1 = measureCombineChart(d1);
|
||||
const m2 = measureCombineChart(d2);
|
||||
|
||||
Promise.all([
|
||||
renderOne(d1, readCfg(1), m1.w, m1.h),
|
||||
renderOne(d2, readCfg(2), m2.w, m2.h)
|
||||
]).then(([c1, c2]) => {
|
||||
let W, H, r1, r2;
|
||||
Promise.all(validCharts.map(c => {
|
||||
const m = measureCombineChart(c.data);
|
||||
return renderOne(c, m.w, m.h);
|
||||
})).then(canvases => {
|
||||
let W, H, scaled;
|
||||
if (direction === 'vertical') {
|
||||
// 竖排(上下):等宽
|
||||
const tw = Math.max(c1.width, c2.width);
|
||||
r1 = { w: tw, h: Math.round(c1.height * tw / c1.width) };
|
||||
r2 = { w: tw, h: Math.round(c2.height * tw / c2.width) };
|
||||
W = tw; H = r1.h + r2.h + gap;
|
||||
// 竖排:等宽
|
||||
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(c1.height, c2.height);
|
||||
r1 = { w: Math.round(c1.width * th / c1.height), h: th };
|
||||
r2 = { w: Math.round(c2.width * th / c2.height), h: th };
|
||||
W = r1.w + r2.w + gap; H = th;
|
||||
// 横排:等高
|
||||
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');
|
||||
@@ -1240,11 +1307,11 @@ function generateCombine() {
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
if (direction === 'vertical') {
|
||||
ctx.drawImage(c1, 0, 0, r1.w, r1.h);
|
||||
ctx.drawImage(c2, 0, r1.h + gap, r2.w, r2.h);
|
||||
let y = 0;
|
||||
scaled.forEach(x => { ctx.drawImage(x.c, 0, y, x.w, x.h); y += x.h + gap; });
|
||||
} else {
|
||||
ctx.drawImage(c1, 0, 0, r1.w, r1.h);
|
||||
ctx.drawImage(c2, r1.w + gap, 0, r2.w, r2.h);
|
||||
let x = 0;
|
||||
scaled.forEach(s => { ctx.drawImage(s.c, x, 0, s.w, s.h); x += s.w + gap; });
|
||||
}
|
||||
|
||||
combinedCanvas = canvas;
|
||||
|
||||
+4
-111
@@ -274,7 +274,7 @@ C, 20, 30, 40</pre>
|
||||
|
||||
<!-- 双图合并配置区 -->
|
||||
<div class="panel-section" id="combineConfigSection" style="display:none;">
|
||||
<h2>🖼️ 双图合并配置</h2>
|
||||
<h2>🖼️ 多图合并配置</h2>
|
||||
|
||||
<div class="config-group">
|
||||
<label>排列方向</label>
|
||||
@@ -284,119 +284,12 @@ C, 20, 30, 40</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="combine-chart-card">
|
||||
<h3>📈 图1</h3>
|
||||
<div class="config-group">
|
||||
<label>标题</label>
|
||||
<input type="text" id="combineTitle1" placeholder="图1标题" oninput="generateCombine()">
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>图表类型</label>
|
||||
<select id="combineType1" onchange="generateCombine()">
|
||||
<option value="bar">柱状图</option>
|
||||
<option value="line">折线图</option>
|
||||
<option value="bar-line">柱状图+折线图混合</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>主题风格</label>
|
||||
<select id="combineTheme1" onchange="generateCombine()">
|
||||
<option value="default">默认</option>
|
||||
<option value="dark">深色</option>
|
||||
<option value="macarons">马卡龙</option>
|
||||
<option value="gradient">渐变</option>
|
||||
<option value="retro">复古</option>
|
||||
<option value="ocean">海洋</option>
|
||||
<option value="forest">森林</option>
|
||||
<option value="sunset">日落</option>
|
||||
<option value="lavender">薰衣草</option>
|
||||
<option value="minimal">极简</option>
|
||||
<option value="cherry">樱花</option>
|
||||
<option value="midnight">午夜</option>
|
||||
<option value="gold">金色</option>
|
||||
<option value="coral">珊瑚</option>
|
||||
<option value="mint">薄荷</option>
|
||||
<option value="slate">石板灰</option>
|
||||
<option value="sky">天空蓝</option>
|
||||
<option value="rose">玫瑰红</option>
|
||||
<option value="amber">琥珀黄</option>
|
||||
<option value="emerald">翡翠绿</option>
|
||||
<option value="indigo">靛蓝色</option>
|
||||
<option value="stone">石灰白</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>显示选项</label>
|
||||
<div class="checkbox-group">
|
||||
<label><input type="checkbox" id="combineLegend1" checked onchange="generateCombine()"> 图例</label>
|
||||
<label><input type="checkbox" id="combineGrid1" checked onchange="generateCombine()"> 网格线</label>
|
||||
<label><input type="checkbox" id="combineLabel1" onchange="generateCombine()"> 数据标签</label>
|
||||
<label><input type="checkbox" id="combineStack1" onchange="generateCombine()"> 堆叠</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>数据</label>
|
||||
<textarea id="combineData1" rows="5" placeholder="图1的CSV数据..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="combine-chart-card">
|
||||
<h3>📈 图2</h3>
|
||||
<div class="config-group">
|
||||
<label>标题</label>
|
||||
<input type="text" id="combineTitle2" placeholder="图2标题" oninput="generateCombine()">
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>图表类型</label>
|
||||
<select id="combineType2" onchange="generateCombine()">
|
||||
<option value="bar">柱状图</option>
|
||||
<option value="line">折线图</option>
|
||||
<option value="bar-line">柱状图+折线图混合</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>主题风格</label>
|
||||
<select id="combineTheme2" onchange="generateCombine()">
|
||||
<option value="default">默认</option>
|
||||
<option value="dark">深色</option>
|
||||
<option value="macarons">马卡龙</option>
|
||||
<option value="gradient">渐变</option>
|
||||
<option value="retro">复古</option>
|
||||
<option value="ocean">海洋</option>
|
||||
<option value="forest">森林</option>
|
||||
<option value="sunset">日落</option>
|
||||
<option value="lavender">薰衣草</option>
|
||||
<option value="minimal">极简</option>
|
||||
<option value="cherry">樱花</option>
|
||||
<option value="midnight">午夜</option>
|
||||
<option value="gold">金色</option>
|
||||
<option value="coral">珊瑚</option>
|
||||
<option value="mint">薄荷</option>
|
||||
<option value="slate">石板灰</option>
|
||||
<option value="sky">天空蓝</option>
|
||||
<option value="rose">玫瑰红</option>
|
||||
<option value="amber">琥珀黄</option>
|
||||
<option value="emerald">翡翠绿</option>
|
||||
<option value="indigo">靛蓝色</option>
|
||||
<option value="stone">石灰白</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>显示选项</label>
|
||||
<div class="checkbox-group">
|
||||
<label><input type="checkbox" id="combineLegend2" checked onchange="generateCombine()"> 图例</label>
|
||||
<label><input type="checkbox" id="combineGrid2" checked onchange="generateCombine()"> 网格线</label>
|
||||
<label><input type="checkbox" id="combineLabel2" onchange="generateCombine()"> 数据标签</label>
|
||||
<label><input type="checkbox" id="combineStack2" onchange="generateCombine()"> 堆叠</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>数据</label>
|
||||
<textarea id="combineData2" rows="5" placeholder="图2的CSV数据..."></textarea>
|
||||
</div>
|
||||
<div id="combineChartsContainer">
|
||||
<!-- 图表卡片由 JS 动态渲染 -->
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary" onclick="addCombineChart()">+ 添加图表</button>
|
||||
<button class="btn btn-primary" onclick="generateCombine()">🖼️ 生成合并图</button>
|
||||
<button class="btn btn-success" onclick="exportImage('png')">📥 下载图片</button>
|
||||
</div>
|
||||
|
||||
@@ -928,7 +928,7 @@ app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'data-chart-tool',
|
||||
version: '1.12.0',
|
||||
version: '1.13.0',
|
||||
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.12.0',
|
||||
version: '1.13.0',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -1055,13 +1055,12 @@ app.get('/api/docs', (req, res) => {
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/combine',
|
||||
description: '将两张图表合并到一张图片中(支持横排/竖排)',
|
||||
description: '将多张图表合并到一张图片中(支持横排/竖排)',
|
||||
'Content-Type': 'application/json',
|
||||
params: {
|
||||
chart1: { type: 'object', required: true, description: '第一张图配置(同 /api/chart 参数)' },
|
||||
chart2: { type: 'object', required: true, description: '第二张图配置(同 /api/chart 参数)' },
|
||||
charts: { type: 'array', required: true, description: '图表配置数组(N 张),每项同 /api/chart 参数;兼容 chart1 + chart2' },
|
||||
direction: { type: 'string', default: 'horizontal', options: ['horizontal', 'vertical'], description: '排列方向:horizontal 横排(左右)/ vertical 竖排(上下)' },
|
||||
gap: { type: 'number', default: 24, description: '两图间距(px)' },
|
||||
gap: { type: 'number', default: 24, description: '图间距(px)' },
|
||||
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' },
|
||||
background: { type: 'string', default: '#ffffff', description: '背景色' }
|
||||
},
|
||||
@@ -1081,22 +1080,29 @@ app.get('/api/docs', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ===== API: 双图合并生成图片 =====
|
||||
// 将两张图(图表/表格均可)合并到一张图片中,支持横排(左右)或竖排(上下)
|
||||
// ===== API: 多图合并生成图片 =====
|
||||
app.post('/api/combine', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const chart1 = body.chart1 || {};
|
||||
const chart2 = body.chart2 || {};
|
||||
// 支持 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 = 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) => {
|
||||
@@ -1115,23 +1121,22 @@ app.post('/api/combine', async (req, res) => {
|
||||
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);
|
||||
const subs = await Promise.all(charts.map(c =>
|
||||
renderSub(c, parseInt(c.width) || 640, parseInt(c.height) || 420)
|
||||
));
|
||||
|
||||
let W, H, aRect, bRect;
|
||||
let W, H, scaled;
|
||||
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)) };
|
||||
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 = aRect.h + bRect.h + gap;
|
||||
H = scaled.reduce((sum, s) => sum + s.h, 0) + gap * (scaled.length - 1);
|
||||
} 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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1142,11 +1147,17 @@ app.post('/api/combine', async (req, res) => {
|
||||
|
||||
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);
|
||||
let y = 0;
|
||||
scaled.forEach(s => {
|
||||
ctx.drawImage(s.img, 0, y * pixelRatio, s.w * pixelRatio, s.h * pixelRatio);
|
||||
y += s.h + gap;
|
||||
});
|
||||
} 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);
|
||||
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');
|
||||
@@ -1154,13 +1165,14 @@ 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-Chart-Width': W,
|
||||
'X-Chart-Height': H
|
||||
});
|
||||
res.send(buffer);
|
||||
} catch (err) {
|
||||
console.error('Combine error:', err);
|
||||
res.status(500).json({ error: '双图合并失败: ' + err.message });
|
||||
res.status(500).json({ error: '多图合并失败: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -205,7 +205,8 @@ body {
|
||||
|
||||
.config-group select,
|
||||
.config-group input[type="text"],
|
||||
.config-group input[type="number"] {
|
||||
.config-group input[type="number"],
|
||||
.config-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -215,8 +216,15 @@ body {
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.config-group textarea {
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.config-group select:focus,
|
||||
.config-group input:focus {
|
||||
.config-group input:focus,
|
||||
.config-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
@@ -478,10 +486,37 @@ body {
|
||||
|
||||
.combine-chart-card h3 {
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.combine-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 1;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
background: #fecaca;
|
||||
}
|
||||
|
||||
/* ===== 表格预览 ===== */
|
||||
|
||||
Reference in New Issue
Block a user