diff --git a/API.md b/API.md index 614a02d..26c6733 100644 --- a/API.md +++ b/API.md @@ -50,6 +50,8 @@ Content-Type: application/json | `leftAxisName` | string | — | `""` | 左轴名称(如 `"销售额(元)"`) | | `rightAxisName` | string | — | `""` | 右轴名称(如 `"增长率(%)"`) | | `seriesTypes` | array | — | `null` | 每系列图表类型,按系列顺序对应,如 `["bar","line"]`(`bar`/`line`/`auto`) | +| `seriesAxis` | array | — | `null` | 每系列坐标轴,按系列顺序对应,如 `[0,1]`(`0`=左轴 `1`=右轴,配合 `dualYAxis`;优先于 `rightAxisSeries`) | +| `rowsAsSeries` | boolean | — | `false` | 数据方向:`false`=列=系列(第一列是横坐标,每列一个系列);`true`=行=系列(第一行是横坐标,每行一个系列) | | `width` | number | — | `800` | 图片宽度(px) | | `height` | number | — | `500` | 图片高度(px) | | `pixelRatio` | number | — | `2` | 像素倍率(越大越清晰) | @@ -178,10 +180,33 @@ Content-Type: application/json | `gap` | number | 否 | 图间距(默认 24px) | | `pixelRatio` | number | 否 | 像素倍率,默认 2(越清晰文件越大) | | `background` | string | 否 | 背景色,默认 `#ffffff` | +| `bigTitle` | string | 否 | 整张图片的大标题(可空),居中显示在顶部 | +| `legendMode` | string | 否 | 图例方式:`own` 每个小图各自图例(默认)/ `shared-top` 共用一套图例放顶部 / `shared-bottom` 共用一套图例放底部(共用时各子图隐藏自己的图例,系列合并去重) | +| `theme` | string | 否 | 大标题/共享图例文字颜色所属主题,默认 `default`(深色用 `dark`) | > 兼容旧参数:`chart1` + `chart2` 仍可传(等价于 `charts: [chart1, chart2]`)。 -每个子图配置支持:`data`(CSV)、`chartType`(bar/line/bar-line/pie/radar)、`title`、`theme`、`showLegend`、`showGrid`、`showLabel`、`stackMode`、`smoothLine`、`width`、`height`。 +每个子图配置支持:`data`(CSV)、`chartType`(bar/line/bar-line/pie/radar)、`title`、`theme`、`showLegend`、`showGrid`、`showLabel`、`stackMode`、`smoothLine`、`width`、`height`,以及**数据方向与多类型双轴**:`rowsAsSeries`(行=系列)、`dualYAxis`、`leftAxisName`、`rightAxisName`、`seriesTypes`、`seriesAxis`。 + +### 高级示例(大标题 + 底部共享图例 + 行=系列 + 柱状/折线双轴) + +```bash +curl -X POST http://127.0.0.1:16016/api/combine \ + -H "Content-Type: application/json" \ + -d '{ + "bigTitle": "经营总览大图", + "legendMode": "shared-bottom", + "direction": "grid", + "cols": 2, + "charts": [ + {"data": "指标, 2023, 2024, 2025\n营收(元), 1200, 1800, 2100\n销量(个), 100, 150, 130", + "rowsAsSeries": true, "chartType": "bar", "title": "营收与销量", + "dualYAxis": true, "leftAxisName": "元", "rightAxisName": "个", + "seriesTypes": ["bar","line"], "seriesAxis": [0,1]}, + {"data": "产品, Q1, Q2\n手机, 1200, 1800\n平板, 800, 950", "chartType": "bar", "title": "季度销售"} + ] + }' -o combine.png +``` ### curl 示例 @@ -248,7 +273,7 @@ curl http://192.168.0.101:16016/api/health { "status": "ok", "service": "data-chart-tool", - "version": "1.1.0", + "version": "1.16.0", "endpoints": { "POST /api/chart": "生成图表图片(JSON body)", "GET /api/chart": "生成图表图片(URL 参数)", diff --git a/README.md b/README.md index 20aca59..ac5f119 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ 一个简洁强大的数据可视化工具,支持 **Web UI** 和 **API** 两种方式生成精美的对比图表和表格图片。  - + ## ✨ 功能特性 @@ -14,6 +14,7 @@ - **饼图** - 环形占比展示(第一列=名称,第一系列=数值) - **雷达图** - 多维度对比(第一列=维度,每个系列=一个多边形) - **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线) +- **数据方向(行=系列)** - 支持"行=系列"布局:第一行是横坐标,每行一个系列,每行可独立设置图表类型(柱状/折线)与左右轴量度(如:营收行=柱状左轴,销量行=折线右轴) ### 📋 表格功能 - **表格图片生成** - 根据数据生成精美的表格图片 @@ -35,15 +36,17 @@ ### 🖼️ 多图合并 - 将多张图表合并到一张图片中,适合对比/汇总场景,**默认2张,可一键不断添加**(支持删除) - 支持**横排(单行)**、**竖排(单列)**、**多行多列网格**(可设每行列数),一键切换实时预览 -- 每张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠 +- 每张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠,以及**数据方向(行=系列)**、**双Y轴(不同左右量度)**、**每系列独立图表类型与坐标轴** +- **图片大标题** - 可编写整张大图的标题,居中显示在顶部(可留空) +- **共用图例** - 图例方式可选:每个小图各自图例 / 共用一套图例放顶部 / 共用一套图例放底部;共用时所有小图的系列合并去重,同名系列只显示一次,各小图隐藏自己的图例 - 支持导出 PNG ### 📡 API 接口 -- **POST /api/chart** - JSON 请求体生成图表(完整参数支持) +- **POST /api/chart** - JSON 请求体生成图表(完整参数支持,含 rowsAsSeries/seriesTypes/seriesAxis/dualYAxis) - **GET /api/chart** - URL 参数生成图表(简单场景) - **POST /api/table** - JSON 请求体生成表格图片 - **GET /api/table** - URL 参数生成表格图片 -- **POST /api/combine** - 多图合并(横排/竖排)生成一张图片 +- **POST /api/combine** - 多图合并(横排/竖排/网格)生成一张图片,支持 bigTitle(大标题)与 legendMode(own/shared-top/shared-bottom 共享图例) - 返回 PNG 图片,支持自定义分辨率和像素倍率 ### 📥 导出功能 diff --git a/app.js b/app.js index ec0f098..9d590be 100644 --- a/app.js +++ b/app.js @@ -76,8 +76,8 @@ document.addEventListener('DOMContentLoaded', () => { } }); -// ===== 数据解析 ===== -function parseData(rawText) { +// ===== 数据解析(rowsAsSeries=true 时“行=系列”:第一行是横坐标,每行一个系列) ===== +function parseData(rawText, rowsAsSeries) { const lines = rawText.trim().split('\n').filter(l => l.trim()); if (lines.length < 2) { alert('数据至少需要包含表头和一行数据'); @@ -100,6 +100,32 @@ function parseData(rawText) { const categories = []; const seriesData = {}; + if (rowsAsSeries) { + // 行=系列:第一列=系列名,表头除第一列外=横坐标 + const names = []; + const nameSet = {}; + rows.slice(1).forEach(r => { + const n = (r[0] || '').trim(); + if (n && !nameSet[n]) { nameSet[n] = true; names.push(n); } + }); + names.forEach(n => { seriesData[n] = []; }); + for (let j = 1; j < headers.length; j++) { + categories.push(headers[j]); + for (let i = 1; i < rows.length; i++) { + const r = rows[i]; + const name = ((r && r[0]) || '').trim(); + if (!seriesData[name]) continue; + const val = parseFloat(r[j]); + seriesData[name].push(isNaN(val) ? 0 : val); + } + } + return { + categories, + seriesNames: names, + seriesData + }; + } + // 第一列是横坐标,其余列是系列 for (let i = 1; i < headers.length; i++) { seriesData[headers[i]] = []; @@ -128,7 +154,7 @@ function generateChart() { return; } - parsedData = parseData(rawText); + parsedData = parseData(rawText, document.getElementById('dataOrientation').value === 'rows'); if (!parsedData) return; // 初始化系列顺序和颜色 @@ -759,6 +785,13 @@ function clearData() { document.getElementById('seriesConfig').innerHTML = '
生成图表后可在此调整各系列的顺序和颜色
'; } +// 数据方向切换(列=系列 / 行=系列):重新解析并重建系列配置 +function onDataOrientationChange() { + if (currentMode === 'chart' && document.getElementById('dataInput').value.trim()) { + generateChart(); + } +} + // ===== 导出图表(SVG 专用) ===== function exportChartSvg() { if (!chartInstance) { @@ -1146,13 +1179,14 @@ let combinedCanvas = null; // 构建单个子图的 echarts option(不依赖全局状态,双图模式专用) function buildCombineChartOption(dataText, cfg) { - const parsed = parseData(dataText); + const parsed = parseData(dataText, !!cfg.rowsAsSeries); if (!parsed) return null; const { title = '', chartType = 'bar', theme = 'default', showLegend = true, showGrid = true, showLabel = false, - stackMode = false, smoothLine = true + stackMode = false, smoothLine = true, + dualYAxis = false, leftAxisName = '', rightAxisName = '', seriesConfig = null } = cfg; const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff'; @@ -1222,7 +1256,11 @@ function buildCombineChartOption(dataText, cfg) { const series = parsed.seriesNames.map((name, idx) => { const color = palette[idx % palette.length]; + const scMap = {}; + (Array.isArray(seriesConfig) ? seriesConfig : []).forEach(s => { if (s && s.name) scMap[s.name] = s; }); + const sc = scMap[name] || {}; let type = chartType === 'bar-line' ? (idx % 2 === 0 ? 'bar' : 'line') : chartType; + if (sc.type && sc.type !== 'auto') type = sc.type; const s = { name, @@ -1232,6 +1270,8 @@ function buildCombineChartOption(dataText, cfg) { emphasis: { focus: 'series' } }; + if (dualYAxis) s.yAxisIndex = (sc.axis === 1 || sc.axis === '1') ? 1 : 0; + if (stackMode) s.stack = 'total'; if (type === 'line') { @@ -1302,7 +1342,27 @@ function buildCombineChartOption(dataText, cfg) { axisLabel: { color: textColor, fontSize: 11, interval: 0, rotate: parsed.categories.length > 10 ? 30 : 0 }, axisTick: { show: false } }, - yAxis: { + yAxis: dualYAxis ? [ + { + type: 'value', + name: leftAxisName || '左轴', + nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: textColor, fontSize: 11 }, + splitLine: { show: showGrid, lineStyle: { color: theme === 'dark' ? '#333' : '#f0f0f0', type: 'dashed' } } + }, + { + type: 'value', + name: rightAxisName || '右轴', + position: 'right', + nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 4, 0, 0] }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: textColor, fontSize: 11 }, + splitLine: { show: false } + } + ] : { type: 'value', axisLine: { show: false }, axisTick: { show: false }, @@ -1315,8 +1375,8 @@ function buildCombineChartOption(dataText, cfg) { } // 根据数据量估算子图尺寸 -function measureCombineChart(dataText) { - const p = parseData(dataText); +function measureCombineChart(dataText, rowsAsSeries) { + const p = parseData(dataText, !!rowsAsSeries); if (!p) return { w: 600, h: 400 }; const w = Math.min(900, Math.max(500, p.categories.length * 70 + 140)); return { w, h: 400 }; @@ -1326,7 +1386,7 @@ function measureCombineChart(dataText) { let combineCharts = []; function defaultCombineChart() { - return { title: '', type: 'bar', theme: 'default', legend: true, grid: true, label: false, stack: false, data: '' }; + return { title: '', type: 'bar', theme: 'default', legend: true, grid: true, label: false, stack: false, data: '', rowsAsSeries: false, dualYAxis: false, leftAxisName: '', rightAxisName: '', seriesConfig: [] }; } function initCombineCharts() { @@ -1378,8 +1438,25 @@ function renderCombineCharts() { + +输入数据后自动显示各系列的图表类型/坐标轴
'; + syncCombineSeriesConfig(i); + const p = parseData(c.data, !!c.rowsAsSeries); + if (!p || !p.seriesNames.length) return '输入数据后自动显示各系列的图表类型/坐标轴
'; + const dual = !!c.dualYAxis; + return p.seriesNames.map((name, idx) => { + const sc = (c.seriesConfig && c.seriesConfig[idx]) || { name, type: 'auto', axis: 0 }; + return `⏳
正在合并...
「行=系列」示例:
指标, 2023, 2024, 2025
营收(元), 1200, 1800, 2100
销量(个), 100, 150, 130
—— 每行可单独设柱状/折线 + 左右轴