From e56c6cb79f0d8ddc507f78a85a7217b5aa89749b Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Wed, 19 Aug 2026 13:30:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=8C=E5=9B=BE=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E4=B8=BA=E5=A4=9A=E5=9B=BE=E5=90=88=E5=B9=B6?= =?UTF-8?q?=20+=20=E6=95=B0=E6=8D=AE=E8=BE=93=E5=85=A5=E6=A1=86=E5=8A=A0?= =?UTF-8?q?=E5=AE=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 合并模式默认2张图,新增'+添加图表'按钮可不断添加,每张图可独立删除 - 每张图卡片:标题/类型/主题/显示选项/数据,动态渲染(combineCharts 数组驱动) - 合并逻辑支持 N 张图:横排等高依次排列、竖排等宽依次堆叠 - 后端 /api/combine 支持 charts 数组(N 张),兼容 chart1+chart2 - 修复合并图数据输入框过窄:.config-group textarea 统一 width:100%(与设置区一致) --- API.md | 23 +++++--- README.md | 8 +-- app.js | 163 +++++++++++++++++++++++++++++++++++++---------------- index.html | 115 ++----------------------------------- server.js | 72 +++++++++++++---------- style.css | 41 +++++++++++++- 6 files changed, 219 insertions(+), 203 deletions(-) diff --git a/API.md b/API.md index 4ee34e7..e72dc86 100644 --- a/API.md +++ b/API.md @@ -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年销售", diff --git a/README.md b/README.md index 16bf71a..2169f74 100644 --- a/README.md +++ b/README.md @@ -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 图片,支持自定义分辨率和像素倍率 ### 📥 导出功能 diff --git a/app.js b/app.js index df12f61..74204e4 100644 --- a/app.js +++ b/app.js @@ -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, '"'); +} + +function renderCombineCharts() { + const container = document.getElementById('combineChartsContainer'); + if (!container) return; + container.innerHTML = combineCharts.map((c, i) => ` +
+
+

📈 图${i + 1}

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + +
+
+
+ + +
+
+ `).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 = '

正在合并...

'; - 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; diff --git a/index.html b/index.html index 8bfab17..995245f 100644 --- a/index.html +++ b/index.html @@ -274,7 +274,7 @@ C, 20, 30, 40 -
-

📈 图1

-
- - -
-
- - -
-
- - -
-
- -
- - - - -
-
-
- - -
-
- -
-

📈 图2

-
- - -
-
- - -
-
- - -
-
- -
- - - - -
-
-
- - -
+
+
+
diff --git a/server.js b/server.js index 06631bc..1d8c5b6 100644 --- a/server.js +++ b/server.js @@ -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 }); } }); diff --git a/style.css b/style.css index e7f240f..b02a54a 100644 --- a/style.css +++ b/style.css @@ -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; } /* ===== 表格预览 ===== */