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 = '
⏳
正在合并...
❌
${err.message}
格式说明:
@@ -208,12 +209,142 @@ C, 20, 30, 40