From e62aac539be3ab0988d8a6870e19656efd114635 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Wed, 19 Aug 2026 12:15:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=8F=8C=E5=9B=BE?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E6=A8=A1=E5=BC=8F=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=A8=AA=E6=8E=92/=E7=AB=96=E6=8E=92=E5=B0=86=E4=B8=A4?= =?UTF-8?q?=E5=BC=A0=E5=9B=BE=E5=90=88=E5=B9=B6=E4=B8=BA=E4=B8=80=E5=BC=A0?= =?UTF-8?q?=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 POST /api/combine 接口(chart1/chart2 + direction/gap/pixelRatio/background) - 前端新增 🖼️ 双图合并模式:两张图独立配置数据/标题/类型/主题 - 横排(左右等高三张图)或竖排(上下等宽),一键切换实时预览 - 修复 @napi-rs/canvas echarts 渲染需先 toBuffer 再 dispose 的关键问题 - 更新 README/API 文档 --- API.md | 73 +++++++++++++++- README.md | 7 ++ app.js | 246 ++++++++++++++++++++++++++++++++++++++++++++++++++++- index.html | 135 ++++++++++++++++++++++++++++- server.js | 111 +++++++++++++++++++++++- style.css | 47 ++++++++++ 6 files changed, 610 insertions(+), 9 deletions(-) diff --git a/API.md b/API.md index 08aec27..801be9e 100644 --- a/API.md +++ b/API.md @@ -1,6 +1,6 @@ # 📡 数据可视化图表生成器 - API 文档 -> 版本:v1.1.0 | 基础地址:`http://192.168.0.101:16016` +> 版本:v1.9.0 | 基础地址:`http://192.168.0.101:16016` --- @@ -10,6 +10,7 @@ |------|------|------| | POST | `/api/chart` | 生成图表图片(JSON body,推荐) | | GET | `/api/chart` | 生成图表图片(URL 参数) | +| POST | `/api/combine` | 双图合并(横排/竖排)生成一张图 | | GET | `/api/health` | 健康检查 | | GET | `/api/docs` | 返回本文档(JSON) | @@ -151,7 +152,75 @@ curl "http://192.168.0.101:16016/api/chart?data=%E4%BA%A7%E5%93%81,Q1,Q2%0A%E6%8 --- -## 3. GET /api/health +## 3. POST /api/combine(双图合并) + +将两张图表合并到一张图片中,支持**横排(左右并排)** 或 **竖排(上下堆叠)** 两种方向,返回 PNG 图片。 + +### 请求 + +``` +POST /api/combine +Content-Type: application/json +``` + +### 参数说明 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `chart1` | object | ✅ | 第一张图配置(同 `/api/chart` 参数:data/chartType/title/theme 等) | +| `chart2` | object | ✅ | 第二张图配置(同 `/api/chart` 参数) | +| `direction` | string | 否 | `horizontal` 横排(默认)/ `vertical` 竖排 | +| `gap` | number | 否 | 两图间距(默认 24px) | +| `pixelRatio` | number | 否 | 像素倍率,默认 2(越清晰文件越大) | +| `background` | string | 否 | 背景色,默认 `#ffffff` | + +每个子图配置支持:`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 '{ + "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 +``` + +**竖排(上下堆叠):** +```bash +curl -X POST http://127.0.0.1:16016/api/combine \ + -H "Content-Type: application/json" \ + -d '{ + "chart1": {"data": "指标, 2023年\n营收, 500\n利润, 80", "title": "营收对比", "chartType": "bar"}, + "chart2": {"data": "月份, 效率\n1月, 85\n2月, 88", "title": "效率趋势", "chartType": "line"}, + "direction": "vertical" + }' -o combine.png +``` + +### 返回 + +PNG 图片二进制流,响应头包含: + +| 响应头 | 说明 | +|--------|------| +| `Content-Type` | `image/png` | +| `X-Combine-Direction` | `horizontal` / `vertical` | +| `X-Chart-Width` / `X-Chart-Height` | 合并图逻辑尺寸 | + +--- + +## 4. GET /api/health 健康检查。 diff --git a/README.md b/README.md index 9481766..94fd6cb 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,18 @@ - 可自定义分割线样式(实线/虚线/点线) - 自动标注左右区域标签 +### 🖼️ 双图合并 +- 将两张图表合并到一张图片中,适合对比/汇总场景 +- 支持**横排(左右并排)** 和 **竖排(上下堆叠)**,一键切换实时预览 +- 两张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠 +- 支持导出 PNG + ### 📡 API 接口 - **POST /api/chart** - JSON 请求体生成图表(完整参数支持) - **GET /api/chart** - URL 参数生成图表(简单场景) - **POST /api/table** - JSON 请求体生成表格图片 - **GET /api/table** - URL 参数生成表格图片 +- **POST /api/combine** - 双图合并(横排/竖排)生成一张图片 - 返回 PNG 图片,支持自定义分辨率和像素倍率 ### 📥 导出功能 diff --git a/app.js b/app.js index 6b40786..ae34fa5 100644 --- a/app.js +++ b/app.js @@ -59,6 +59,14 @@ document.addEventListener('DOMContentLoaded', () => { // 默认加载第一个示例 document.getElementById('dataInput').value = sampleDataSets[0].data; generateChart(); + + // 预填双图合并示例 + 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 = '年度增长趋势'; + generateCombine(); }); // ===== 数据解析 ===== @@ -625,16 +633,24 @@ function switchMode(mode) { // 更新按钮状态 document.getElementById('btnChartMode').classList.toggle('active', mode === 'chart'); document.getElementById('btnTableMode').classList.toggle('active', mode === 'table'); + document.getElementById('btnCombineMode').classList.toggle('active', mode === 'combine'); // 显示/隐藏配置区 + document.getElementById('dataInputSection').style.display = mode === 'combine' ? 'none' : 'block'; document.getElementById('chartConfigSection').style.display = mode === 'chart' ? 'block' : 'none'; document.getElementById('tableConfigSection').style.display = mode === 'table' ? 'block' : 'none'; document.getElementById('seriesConfigSection').style.display = mode === 'chart' ? 'block' : 'none'; document.getElementById('splitConfigSection').style.display = mode === 'chart' ? 'block' : 'none'; + document.getElementById('combineConfigSection').style.display = mode === 'combine' ? 'block' : 'none'; // 更新生成按钮 const btnGenerate = document.getElementById('btnGenerate'); - btnGenerate.textContent = mode === 'chart' ? '🎨 生成图表' : '📋 生成表格'; + if (mode === 'combine') { + btnGenerate.style.display = 'none'; // 双图模式用自己的按钮 + } else { + btnGenerate.style.display = ''; + btnGenerate.textContent = mode === 'chart' ? '🎨 生成图表' : '📋 生成表格'; + } // 更新导出按钮 const btnExportSvg = document.getElementById('btnExportSvg'); @@ -648,8 +664,10 @@ function switchMode(mode) { function generate() { if (currentMode === 'chart') { generateChart(); - } else { + } else if (currentMode === 'table') { generateTable(); + } else { + generateCombine(); } } @@ -710,8 +728,10 @@ function updateTable() { function exportImage(format) { if (currentMode === 'chart') { exportChart(format); - } else { + } else if (currentMode === 'table') { exportTable(format); + } else { + exportCombine(); } } @@ -741,11 +761,12 @@ function quickSwitchTheme(theme) { // 图表模式:更新主题选择器并重新渲染 document.getElementById('themeStyle').value = theme; updateChart(); - } else { + } else if (currentMode === 'table') { // 表格模式:更新主题选择器并重新请求 document.getElementById('tableTheme').value = theme; updateTable(); } + // 双图合并模式:仅切换按钮高亮,不联动(两图各自独立主题) } // ===== 同步风格按钮状态(下拉框变更时) ===== @@ -767,3 +788,220 @@ function initThemeButtons() { document.addEventListener('DOMContentLoaded', () => { initThemeButtons(); }); + +// ===== 双图合并 ===== +let combinedCanvas = null; + +// 构建单个子图的 echarts option(不依赖全局状态,双图模式专用) +function buildCombineChartOption(dataText, cfg) { + const parsed = parseData(dataText); + if (!parsed) return null; + + const { + title = '', chartType = 'bar', theme = 'default', + showLegend = true, showGrid = true, showLabel = false, + stackMode = false, smoothLine = true + } = cfg; + + const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff'; + const textColor = theme === 'dark' ? '#e0e0e0' : '#333333'; + const axisLineColor = theme === 'dark' ? '#444' : '#ddd'; + const palette = colorPalettes[theme] || colorPalettes.default; + + const series = parsed.seriesNames.map((name, idx) => { + const color = palette[idx % palette.length]; + let type = chartType === 'bar-line' ? (idx % 2 === 0 ? 'bar' : 'line') : chartType; + + const s = { + name, + type, + data: [...parsed.seriesData[name]], + itemStyle: { color }, + emphasis: { focus: 'series' } + }; + + if (stackMode) s.stack = 'total'; + + if (type === 'line') { + s.smooth = smoothLine; + s.lineStyle = { width: 3 }; + s.symbolSize = 8; + s.areaStyle = theme === 'gradient' ? { opacity: 0.15 } : undefined; + } + + if (type === 'bar') { + s.barMaxWidth = 40; + s.itemStyle.borderRadius = stackMode ? [0, 0, 0, 0] : [4, 4, 0, 0]; + if (theme === 'gradient') { + s.itemStyle.color = new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color }, + { offset: 1, color: adjustColor(color, 40) } + ]); + } + } + + if (showLabel) { + s.label = { + show: true, + position: 'top', + fontSize: 11, + color: textColor, + formatter: (p) => { + if (p.value >= 10000) return (p.value / 10000).toFixed(1) + 'w'; + if (p.value >= 1000) return (p.value / 1000).toFixed(1) + 'k'; + return p.value; + } + }; + } + + return s; + }); + + return { + backgroundColor: bgColor, + title: title ? { + text: title, + left: 'center', + top: 10, + textStyle: { color: textColor, fontSize: 16, fontWeight: 600 } + } : undefined, + tooltip: { + trigger: 'axis', + backgroundColor: theme === 'dark' ? 'rgba(30,30,50,0.95)' : 'rgba(255,255,255,0.95)', + borderColor: theme === 'dark' ? '#555' : '#eee', + textStyle: { color: textColor } + }, + legend: showLegend ? { + show: true, + top: title ? 40 : 10, + textStyle: { color: textColor, fontSize: 12 }, + itemGap: 20, + icon: 'roundRect' + } : { show: false }, + grid: { + left: '3%', right: '4%', bottom: '3%', + top: showLegend ? (title ? 70 : 45) : (title ? 50 : 30), + containLabel: true + }, + xAxis: { + type: 'category', + data: parsed.categories, + axisLine: { lineStyle: { color: axisLineColor } }, + axisLabel: { color: textColor, fontSize: 11, interval: 0, rotate: parsed.categories.length > 10 ? 30 : 0 }, + axisTick: { show: false } + }, + yAxis: { + type: 'value', + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: textColor, fontSize: 11 }, + splitLine: { show: showGrid, lineStyle: { color: theme === 'dark' ? '#333' : '#f0f0f0', type: 'dashed' } } + }, + series, + animation: false + }; +} + +// 根据数据量估算子图尺寸 +function measureCombineChart(dataText) { + const p = parseData(dataText); + if (!p) return { w: 600, h: 400 }; + const w = Math.min(900, Math.max(500, p.categories.length * 70 + 140)); + return { w, h: 400 }; +} + +// 生成合并图 +function generateCombine() { + const d1 = document.getElementById('combineData1').value.trim(); + const d2 = document.getElementById('combineData2').value.trim(); + if (!d1 || !d2) 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); + 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;`; + document.body.appendChild(holder); + const chart = echarts.init(holder, null, { renderer: 'canvas' }); + chart.setOption(option); + setTimeout(() => { + const cvs = holder.querySelector('canvas'); + if (!cvs) { reject(new Error('子图渲染失败')); return; } + resolve(cvs); + chart.dispose(); + holder.remove(); + }, 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; + 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; + } 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 canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + 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); + } else { + ctx.drawImage(c1, 0, 0, r1.w, r1.h); + ctx.drawImage(c2, r1.w + gap, 0, r2.w, r2.h); + } + + combinedCanvas = canvas; + const url = canvas.toDataURL('image/png'); + chartArea.innerHTML = `
合并图预览
`; + }).catch(err => { + chartArea.innerHTML = `

${err.message}

`; + }); +} + +// 导出合并图 +function exportCombine() { + if (!combinedCanvas) { + alert('请先生成合并图'); + return; + } + const a = document.createElement('a'); + a.href = combinedCanvas.toDataURL('image/png'); + a.download = 'combine.png'; + a.click(); +} diff --git a/index.html b/index.html index 5406976..0d9969e 100644 --- a/index.html +++ b/index.html @@ -23,11 +23,12 @@
+
-
+

📝 数据输入

格式说明:

@@ -208,12 +209,142 @@ C, 20, 30, 40
-
+
+ + +
diff --git a/server.js b/server.js index e661fb6..236403a 100644 --- a/server.js +++ b/server.js @@ -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`); }); diff --git a/style.css b/style.css index 3ffbdb5..cc4e369 100644 --- a/style.css +++ b/style.css @@ -428,6 +428,53 @@ body { color: white; } +/* ===== 双图合并 ===== */ +.combine-direction { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.dir-option { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: #f8fafc; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; +} + +.dir-option:hover { + border-color: var(--primary); +} + +.dir-option:has(input:checked) { + border-color: var(--primary); + background: #eef2ff; + color: var(--primary); + font-weight: 500; +} + +.combine-chart-card { + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px; + margin-bottom: 14px; + background: #f8fafc; +} + +.combine-chart-card h3 { + font-size: 0.95rem; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px dashed var(--border); + color: var(--text); +} + /* ===== 表格预览 ===== */ .table-preview { width: 100%;