feat: 数据方向(行=系列)+每系列类型/双轴 + 多图大标题/共享图例 v1.16.0

- 图表模式新增'数据方向':列=系列(默认)/行=系列(第一行是横坐标,每行一个系列),每行可独立设柱状/折线 + 左右轴量度
- 后端 buildChartOption/GET /api/chart 支持 rowsAsSeries + seriesAxis(按系列下标指定左右轴)
- 多图合并每张小图新增:行=系列、双Y轴(左右量度不同)、每系列独立图表类型与坐标轴配置
- 多图合并新增图片大标题(bigTitle),居中显示在顶部
- 多图合并新增图例方式(legendMode):own 各自图例(默认)/shared-top 共用图例顶部/shared-bottom 共用图例底部,共用时各子图隐藏自己的图例、系列合并去重、超宽自动换行
- /api/combine 后端同步支持 bigTitle/legendMode(含深色主题文字适配)
- 收藏功能完整支持新字段(combine 大标题/图例方式/每系列配置,chart 数据方向),编辑恢复正常
- 文档/API.md/README 更新,版本 1.16.0
This commit is contained in:
2026-08-22 12:03:05 +08:00
parent 94aa37e612
commit 2cc6491440
6 changed files with 565 additions and 113 deletions
+27 -2
View File
@@ -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 参数)",
+7 -4
View File
@@ -3,7 +3,7 @@
一个简洁强大的数据可视化工具,支持 **Web UI****API** 两种方式生成精美的对比图表和表格图片。
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Version](https://img.shields.io/badge/version-1.8.0-green.svg)
![Version](https://img.shields.io/badge/version-1.16.0-green.svg)
## ✨ 功能特性
@@ -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(大标题)与 legendModeown/shared-top/shared-bottom 共享图例)
- 返回 PNG 图片,支持自定义分辨率和像素倍率
### 📥 导出功能
+274 -45
View File
@@ -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 = '<p class="hint-text">生成图表后可在此调整各系列的顺序和颜色</p>';
}
// 数据方向切换(列=系列 / 行=系列):重新解析并重建系列配置
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() {
<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>
<label><input type="checkbox" ${c.rowsAsSeries ? 'checked' : ''} onchange="updateCombineChart(${i},'rowsAsSeries',this.checked)"> 行=系列</label>
</div>
</div>
<div class="config-group">
<label><input type="checkbox" ${c.dualYAxis ? 'checked' : ''} onchange="updateCombineChart(${i},'dualYAxis',this.checked)"> 双Y轴(不同左右量度)</label>
</div>
${c.dualYAxis ? `
<div class="config-group">
<label>左右轴名称(量度可不同)</label>
<div class="res-input-row">
<input type="text" value="${escHtml(c.leftAxisName)}" placeholder="左轴,如:销售额" oninput="updateCombineChart(${i},'leftAxisName',this.value)">
<span class="res-times">/</span>
<input type="text" value="${escHtml(c.rightAxisName)}" placeholder="右轴,如:增长率(%)" oninput="updateCombineChart(${i},'rightAxisName',this.value)">
</div>
</div>` : ''}
<div class="config-group">
<label>系列图表类型 / 坐标轴</label>
<div class="combine-series-cfg" id="combineSeriesCfg${i}">${renderCombineSeriesConfigHTML(i)}</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>
@@ -1391,7 +1468,84 @@ function renderCombineCharts() {
function updateCombineChart(i, field, value) {
if (!combineCharts[i]) return;
combineCharts[i][field] = value;
if (field === 'dualYAxis' || field === 'rowsAsSeries') {
syncCombineSeriesConfig(i);
renderCombineCharts();
generateCombine();
return;
}
if (field === 'data') {
syncCombineSeriesConfig(i);
const block = document.getElementById('combineSeriesCfg' + i);
if (block) block.innerHTML = renderCombineSeriesConfigHTML(i);
}
generateCombine();
}
// ===== 多图合并:系列配置(每行/列可独立设图表类型与左右轴) =====
function syncCombineSeriesConfig(i) {
const c = combineCharts[i];
if (!c) return;
const p = parseData(c.data, !!c.rowsAsSeries);
if (!p) return;
const old = Array.isArray(c.seriesConfig) ? c.seriesConfig : [];
const oldMap = {};
old.forEach(o => { if (o && o.name) oldMap[o.name] = o; });
c.seriesConfig = p.seriesNames.map(n => {
const prev = oldMap[n] || {};
return { name: n, type: prev.type || 'auto', axis: prev.axis !== undefined ? prev.axis : 0 };
});
}
function renderCombineSeriesConfigHTML(i) {
const c = combineCharts[i];
if (!c) return '<p class="hint-text">输入数据后自动显示各系列的图表类型/坐标轴</p>';
syncCombineSeriesConfig(i);
const p = parseData(c.data, !!c.rowsAsSeries);
if (!p || !p.seriesNames.length) return '<p class="hint-text">输入数据后自动显示各系列的图表类型/坐标轴</p>';
const dual = !!c.dualYAxis;
return p.seriesNames.map((name, idx) => {
const sc = (c.seriesConfig && c.seriesConfig[idx]) || { name, type: 'auto', axis: 0 };
return `<div class="combine-series-row">
<span class="combine-series-name" title="${escHtml(name)}">${escHtml(name)}</span>
<select onchange="updateCombineSeriesCfg(${i}, ${idx}, 'type', this.value)" title="图表类型">
<option value="auto" ${sc.type === 'auto' ? 'selected' : ''}>自动</option>
<option value="bar" ${sc.type === 'bar' ? 'selected' : ''}>柱状</option>
<option value="line" ${sc.type === 'line' ? 'selected' : ''}>折线</option>
</select>
${dual ? `<select onchange="updateCombineSeriesCfg(${i}, ${idx}, 'axis', this.value)" title="坐标轴">
<option value="0" ${String(sc.axis) === '0' ? 'selected' : ''}>左轴</option>
<option value="1" ${String(sc.axis) === '1' ? 'selected' : ''}>右轴</option>
</select>` : ''}
</div>`;
}).join('');
}
function updateCombineSeriesCfg(i, idx, field, value) {
const c = combineCharts[i];
if (!c) return;
syncCombineSeriesConfig(i);
if (c.seriesConfig && c.seriesConfig[idx]) c.seriesConfig[idx][field] = value;
generateCombine();
}
// 多图合并:共享图例条目(所有子图系列合并,按名称去重)
function buildCombineLegendItems() {
const items = [];
const seen = {};
combineCharts.forEach(c => {
if (!c.data || !c.data.trim()) return;
const p = parseData(c.data, !!c.rowsAsSeries);
if (!p) return;
const palette = colorPalettes[c.theme] || colorPalettes.default;
p.seriesNames.forEach((name, idx) => {
if (!seen[name]) {
seen[name] = true;
items.push({ name, color: palette[idx % palette.length] });
}
});
});
return items;
}
function addCombineChart() {
@@ -1421,13 +1575,19 @@ function generateCombine() {
const direction = document.querySelector('input[name="combineDirection"]:checked').value;
const gap = 24;
const bigTitle = document.getElementById('combineBigTitle').value;
const legendMode = document.getElementById('combineLegendMode').value; // own | shared-top | shared-bottom
const sharedLegend = legendMode !== 'own';
const chartArea = document.getElementById('chartArea');
chartArea.innerHTML = '<div class="placeholder"><p>⏳</p><p>正在合并...</p></div>';
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
showLegend: sharedLegend ? false : c.legend, showGrid: c.grid, showLabel: c.label, stackMode: c.stack,
rowsAsSeries: !!c.rowsAsSeries,
dualYAxis: !!c.dualYAxis, leftAxisName: c.leftAxisName || '', rightAxisName: c.rightAxisName || '',
seriesConfig: c.seriesConfig
});
if (!option) { reject(new Error('数据格式错误')); return; }
const holder = document.createElement('div');
@@ -1450,58 +1610,118 @@ function generateCombine() {
});
Promise.all(validCharts.map(c => {
const m = measureCombineChart(c.data);
const m = measureCombineChart(c.data, c.rowsAsSeries);
return renderOne(c, m.w, m.h);
})).then(canvases => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// ---- 基础布局(不含大标题/共享图例)----
let W, H, cells = [];
if (direction === 'grid') {
// 多行多列网格:所有子图统一 contain 到最大单元格,按列填充
const cols = Math.max(1, parseInt(document.getElementById('gridCols').value) || 2);
const rows = Math.ceil(canvases.length / cols);
const cellW = Math.max(...canvases.map(c => c.width));
const cellH = Math.max(...canvases.map(c => c.height));
canvas.width = cols * cellW + (cols - 1) * gap;
canvas.height = rows * cellH + (rows - 1) * gap;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
W = cols * cellW + (cols - 1) * gap;
H = rows * cellH + (rows - 1) * gap;
canvases.forEach((c, i) => {
const r = Math.floor(i / cols), col = i % cols;
const scale = Math.min(cellW / c.width, cellH / c.height);
const dw = Math.round(c.width * scale), dh = Math.round(c.height * scale);
const x = col * (cellW + gap) + Math.round((cellW - dw) / 2);
const y = r * (cellH + gap) + Math.round((cellH - dh) / 2);
ctx.drawImage(c, x, y, dw, dh);
cells.push({ c, x: col * (cellW + gap) + Math.round((cellW - dw) / 2), y: r * (cellH + gap) + Math.round((cellH - dh) / 2), w: dw, h: dh });
});
} else {
let W, H, scaled;
if (direction === 'vertical') {
// 竖排:等宽
} else if (direction === 'vertical') {
const tw = Math.max(...canvases.map(c => c.width));
scaled = canvases.map(c => ({ c, w: tw, h: Math.round(c.height * tw / c.width) }));
let y = 0;
canvases.forEach(c => {
const h = Math.round(c.height * tw / c.width);
cells.push({ c, x: 0, y, w: tw, h });
y += h + gap;
});
W = tw;
H = scaled.reduce((s, x) => s + x.h, 0) + gap * (scaled.length - 1);
H = y - gap;
} else {
// 横排:等高
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);
let x = 0;
canvases.forEach(c => {
const w = Math.round(c.width * th / c.height);
cells.push({ c, x, y: 0, w, h: th });
x += w + gap;
});
W = x - gap;
H = th;
}
canvas.width = W;
canvas.height = H;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, W, H);
if (direction === 'vertical') {
let y = 0;
scaled.forEach(x => { ctx.drawImage(x.c, 0, y, x.w, x.h); y += x.h + gap; });
} else {
let x = 0;
scaled.forEach(s => { ctx.drawImage(s.c, x, 0, s.w, s.h); x += s.w + gap; });
// ---- 共享图例:按宽度换行计算 ----
const legendFont = 13, swatch = 14, sw = 5, ig = 18, lineH = legendFont + 10;
let legendRows = [], legendH = 0;
if (sharedLegend) {
const items = buildCombineLegendItems();
const tc = document.createElement('canvas'); tc.width = 10; tc.height = 10;
const tctx = tc.getContext('2d');
tctx.font = `${legendFont}px sans-serif`;
const maxW = W - 20;
let cur = [], curW = 0;
items.forEach(it => {
const itemW = swatch + sw + tctx.measureText(it.name).width;
if (cur.length && curW + ig + itemW > maxW) { legendRows.push(cur); cur = []; curW = 0; }
cur.push(it);
curW += itemW + (cur.length > 1 ? ig : 0);
});
if (cur.length) legendRows.push(cur);
legendH = legendRows.length * lineH + 8;
}
// ---- 大标题区高度 ----
const titleFont = 34;
const titleH = bigTitle ? titleFont + 26 : 0;
const topOffset = titleH + (legendMode === 'shared-top' ? legendH + 12 : 0);
const bottomOffset = legendMode === 'shared-bottom' ? legendH + 12 : 0;
const totalH = H + topOffset + bottomOffset;
// ---- 绘制最终画布 ----
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = totalH;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, W, totalH);
if (bigTitle) {
ctx.font = `bold ${titleFont}px sans-serif`;
ctx.fillStyle = '#333333';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(bigTitle, W / 2, (titleH - 26) / 2 + 2);
}
const drawLegendBlock = (topY) => {
legendRows.forEach((row, ri) => {
ctx.font = `${legendFont}px sans-serif`;
let totalW = 0;
row.forEach(it => { totalW += swatch + sw + ctx.measureText(it.name).width + ig; });
totalW -= ig;
let cx = (W - totalW) / 2;
row.forEach(it => {
ctx.fillStyle = it.color;
ctx.fillRect(cx, topY + ri * lineH + (legendFont - swatch) / 2 + 2, swatch, swatch);
ctx.fillStyle = '#333333';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.font = `${legendFont}px sans-serif`;
ctx.fillText(it.name, cx + swatch + sw, topY + ri * lineH + legendFont / 2 + 2);
cx += swatch + sw + ctx.measureText(it.name).width + ig;
});
});
};
if (sharedLegend && legendMode === 'shared-top') {
drawLegendBlock(titleH + 4);
}
cells.forEach(cell => {
ctx.drawImage(cell.c, cell.x, cell.y + topOffset, cell.w, cell.h);
});
if (sharedLegend && legendMode === 'shared-bottom') {
drawLegendBlock(topOffset + H + 8);
}
combinedCanvas = canvas;
@@ -1523,6 +1743,7 @@ function collectChartConfig() {
return {
mode: 'chart',
data: document.getElementById('dataInput').value,
rowsAsSeries: document.getElementById('dataOrientation').value === 'rows',
chartType: document.getElementById('chartType').value,
title: document.getElementById('chartTitle').value,
theme: document.getElementById('themeStyle').value,
@@ -1561,9 +1782,14 @@ function collectTableConfig() {
function collectCombineConfig() {
return {
mode: 'combine',
charts: combineCharts.map(c => ({ ...c })),
charts: combineCharts.map(c => ({
...c,
seriesConfig: Array.isArray(c.seriesConfig) ? c.seriesConfig.map(s => ({ ...s })) : []
})),
direction: (document.querySelector('input[name="combineDirection"]:checked') || {}).value || 'horizontal',
gridCols: parseInt(document.getElementById('gridCols').value) || 2
gridCols: parseInt(document.getElementById('gridCols').value) || 2,
bigTitle: document.getElementById('combineBigTitle').value,
legendMode: document.getElementById('combineLegendMode').value
};
}
@@ -1576,7 +1802,7 @@ function collectConfig() {
function favoriteTitle(cfg) {
if (cfg.mode === 'chart') return cfg.title || '未命名图表';
if (cfg.mode === 'table') return cfg.title || '未命名表格';
return '多图合并';
return cfg.bigTitle || '多图合并';
}
// 点击「⭐ 收藏」:把当前图/表按原始大小保存到收藏区
@@ -1723,6 +1949,7 @@ function applyFavoriteConfig(fav) {
if (mode === 'chart') {
document.getElementById('dataInput').value = cfg.data || '';
document.getElementById('dataOrientation').value = cfg.rowsAsSeries ? 'rows' : 'columns';
document.getElementById('chartType').value = cfg.chartType || 'bar';
document.getElementById('chartTitle').value = cfg.title || '';
document.getElementById('themeStyle').value = cfg.theme || 'default';
@@ -1763,6 +1990,8 @@ function applyFavoriteConfig(fav) {
if (Array.isArray(cfg.charts) && cfg.charts.length) {
combineCharts = cfg.charts.map(c => ({ ...defaultCombineChart(), ...c }));
}
document.getElementById('combineBigTitle').value = cfg.bigTitle || '';
document.getElementById('combineLegendMode').value = cfg.legendMode || 'own';
const dir = document.querySelector(`input[name="combineDirection"][value="${cfg.direction || 'horizontal'}"]`);
if (dir) dir.checked = true;
document.getElementById('gridCols').value = cfg.gridCols || 2;
+24
View File
@@ -66,6 +66,15 @@ C, 20, 30, 40</pre>
</select>
</div>
<div class="config-group">
<label>数据方向</label>
<select id="dataOrientation" onchange="onDataOrientationChange()">
<option value="columns">列=系列(第一列是横坐标,每列一个系列)</option>
<option value="rows">行=系列(第一行是横坐标,每行一个系列)</option>
</select>
<p class="hint-text">「行=系列」示例:<br>指标, 2023, 2024, 2025<br>营收(元), 1200, 1800, 2100<br>销量(个), 100, 150, 130<br>—— 每行可单独设柱状/折线 + 左右轴</p>
</div>
<div class="config-group">
<label>标题</label>
<input type="text" id="chartTitle" placeholder="图表标题" oninput="updateChart()">
@@ -284,6 +293,21 @@ C, 20, 30, 40</pre>
<div class="panel-section" id="combineConfigSection" style="display:none;">
<h2>🖼️ 多图合并配置</h2>
<div class="config-group">
<label>图片大标题(可选)</label>
<input type="text" id="combineBigTitle" placeholder="整张大图的大标题,留空则不显示" oninput="generateCombine()">
</div>
<div class="config-group">
<label>图例方式</label>
<select id="combineLegendMode" onchange="generateCombine()">
<option value="own">每个小图各自图例</option>
<option value="shared-top">共用一套图例(顶部)</option>
<option value="shared-bottom">共用一套图例(底部)</option>
</select>
<p class="hint-text">共用图例时:所有小图的系列合并去重,放在整张图片顶部/底部,各小图隐藏自己的图例;同名系列只显示一次</p>
</div>
<div class="config-group">
<label>排列方式</label>
<div class="combine-direction">
+184 -49
View File
@@ -45,8 +45,8 @@ const colorPalettes = {
mint: ['#00bfa5', '#26a69a', '#4db6ac', '#80cbc4', '#b2dfdb', '#00897b', '#00796b', '#004d40', '#009688']
};
// ===== 数据解析(与前端一致 =====
function parseData(rawText) {
// ===== 数据解析(rowsAsSeries=true 时“行=系列”:第一行是横坐标,每行一个系列 =====
function parseData(rawText, rowsAsSeries) {
const lines = rawText.trim().split('\n').filter(l => l.trim());
if (lines.length < 2) {
throw new Error('数据至少需要包含表头和一行数据');
@@ -68,6 +68,32 @@ function parseData(rawText) {
const categories = [];
const seriesData = {};
if (rowsAsSeries) {
// 行=系列:第一列=系列名,表头除第一列外=横坐标
const names = [];
const nameSet = {};
rows.slice(1).forEach(r => {
const n = ((r && 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]] = [];
}
@@ -119,10 +145,11 @@ function buildChartOption(params) {
rightAxisSeries = null,
leftAxisName = '',
rightAxisName = '',
seriesTypes = null
seriesTypes = null,
seriesAxis = null
} = params;
const parsedData = parseData(data);
const parsedData = parseData(data, params.rowsAsSeries);
const palette = colorPalettes[theme] || colorPalettes.default;
const seriesColorsArr = customColors || parsedData.seriesNames.map((_, i) => palette[i % palette.length]);
@@ -225,10 +252,16 @@ function buildChartOption(params) {
}
};
// 双Y轴:rightAxisSeries 指定右轴系列名
// 双Y轴:rightAxisSeries 按名称指定右轴seriesAxis 按系列下标(0左 1右)
if (dualYAxis) {
let axisIdx = 0;
if (Array.isArray(seriesAxis) && seriesAxis[idx] !== undefined && seriesAxis[idx] !== null && seriesAxis[idx] !== '') {
axisIdx = Number(seriesAxis[idx]) === 1 ? 1 : 0;
} else {
const rightNames = Array.isArray(rightAxisSeries) ? rightAxisSeries : (rightAxisSeries ? [rightAxisSeries] : []);
seriesItem.yAxisIndex = rightNames.includes(name) ? 1 : 0;
axisIdx = rightNames.includes(name) ? 1 : 0;
}
seriesItem.yAxisIndex = axisIdx;
}
if (stackMode) {
@@ -479,6 +512,8 @@ app.get('/api/chart', (req, res) => {
leftAxisName: req.query.leftAxisName || '',
rightAxisName: req.query.rightAxisName || '',
seriesTypes: req.query.seriesTypes ? req.query.seriesTypes.split(',') : null,
seriesAxis: req.query.seriesAxis ? req.query.seriesAxis.split(',').map(Number) : null,
rowsAsSeries: req.query.rowsAsSeries === 'true',
width: parseInt(req.query.width) || 800,
height: parseInt(req.query.height) || 500,
format: req.query.format || 'png',
@@ -995,7 +1030,7 @@ app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
service: 'data-chart-tool',
version: '1.15.0',
version: '1.16.0',
endpoints: {
'POST /api/chart': '生成图表图片(JSON body',
'GET /api/chart': '生成图表图片(URL 参数)',
@@ -1015,7 +1050,7 @@ app.get('/api/health', (req, res) => {
app.get('/api/docs', (req, res) => {
res.json({
name: '数据可视化图表生成器 API',
version: '1.15.0',
version: '1.16.0',
endpoints: [
{
method: 'POST',
@@ -1045,7 +1080,9 @@ app.get('/api/docs', (req, res) => {
rightAxisSeries: { type: 'array', default: null, description: '右轴系列名列表(如 ["利润"],指定哪些系列用右轴)' },
leftAxisName: { type: 'string', default: '', description: '左轴名称' },
rightAxisName: { type: 'string', default: '', description: '右轴名称' },
seriesTypes: { type: 'array', default: null, description: '每系列图表类型(如 ["bar","line"],按系列顺序对应,bar/line/auto' }
seriesTypes: { type: 'array', default: null, description: '每系列图表类型(如 ["bar","line"],按系列顺序对应,bar/line/auto' },
seriesAxis: { type: 'array', default: null, description: '每系列坐标轴(如 [0,1]0=左轴 1=右轴,配合 dualYAxis 使用;优先于 rightAxisSeries' },
rowsAsSeries: { type: 'boolean', default: false, description: '数据方向:false=列=系列(第一列是横坐标,每列一个系列);true=行=系列(第一行是横坐标,每行一个系列)' }
},
returns: 'image/png',
example: {
@@ -1127,15 +1164,18 @@ app.get('/api/docs', (req, res) => {
{
method: 'POST',
path: '/api/combine',
description: '将多张图表合并到一张图片中(支持横排/竖排)',
description: '将多张图表合并到一张图片中(支持横排/竖排/网格,支持整图大标题与共享图例',
'Content-Type': 'application/json',
params: {
charts: { type: 'array', required: true, description: '图表配置数组(N 张),每项同 /api/chart 参数;兼容 chart1 + chart2' },
charts: { type: 'array', required: true, description: '图表配置数组(N 张),每项同 /api/chart 参数(含 seriesTypes/seriesAxis/dualYAxis 等);兼容 chart1 + chart2' },
direction: { type: 'string', default: 'horizontal', options: ['horizontal', 'vertical', 'grid'], description: '排布方式:horizontal 横排(单行)/ vertical 竖排(单列)/ grid 多行多列' },
cols: { type: 'number', default: 2, description: '多行多列时的每行列数(仅 grid 生效)' },
gap: { type: 'number', default: 24, description: '图间距(px)' },
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' },
background: { type: 'string', default: '#ffffff', description: '背景色' }
background: { type: 'string', default: '#ffffff', description: '背景色' },
bigTitle: { type: 'string', default: '', description: '整张图片的大标题(可空)' },
legendMode: { type: 'string', default: 'own', options: ['own', 'shared-top', 'shared-bottom'], description: '图例方式:own 每个小图各自图例 / shared-top 共用图例放顶部 / shared-bottom 共用图例放底部(共用时各子图隐藏自己的图例)' },
theme: { type: 'string', default: 'default', description: '大标题/共享图例文字颜色所属主题(深色用 dark)' }
},
returns: 'image/png',
example: {
@@ -1187,6 +1227,27 @@ app.get('/api/docs', (req, res) => {
});
});
// ===== 多图合并:收集共享图例条目(按名称去重) =====
function combineLegendItems(charts) {
const items = [];
const seen = {};
charts.forEach(c => {
if (!c || !c.data) return;
let p;
try { p = parseData(c.data, c.rowsAsSeries); } catch (e) { return; }
if (!p || !p.seriesNames) return;
const palette = colorPalettes[c.theme] || colorPalettes.default;
const custom = Array.isArray(c.seriesColors) ? c.seriesColors : null;
p.seriesNames.forEach((name, idx) => {
if (!seen[name]) {
seen[name] = true;
items.push({ name, color: custom ? (custom[idx] || palette[idx % palette.length]) : palette[idx % palette.length] });
}
});
});
return items;
}
// ===== API: 多图合并生成图片 =====
app.post('/api/combine', async (req, res) => {
try {
@@ -1210,11 +1271,18 @@ app.post('/api/combine', async (req, res) => {
const gap = parseInt(body.gap) || 24;
const pixelRatio = parseInt(body.pixelRatio) || 2;
const background = body.background || '#ffffff';
const bigTitle = String(body.bigTitle || '');
const legendMode = ['shared-top', 'shared-bottom'].includes(body.legendMode) ? body.legendMode : 'own';
const sharedLegend = legendMode !== 'own';
const theme = body.theme || 'default';
const textColor = theme === 'dark' ? '#e0e0e0' : '#333333';
// 渲染单个子图(直接按目标尺寸渲染,避免二次缩放损失)
// 渲染单个子图(共享图例时隐藏各子图自己的图例;直接按目标尺寸渲染,避免二次缩放损失)
// 注意:必须先 toBuffer 再 chart.dispose()dispose 会清空 canvas 内容!
const renderSub = async (params, w, h) => {
const option = buildChartOption(params);
const p = { ...params };
if (sharedLegend) p.showLegend = false;
const option = buildChartOption(p);
const c = createCanvas(w * pixelRatio, h * pixelRatio);
const chart = echarts.init(c, null, {
renderer: 'canvas',
@@ -1233,10 +1301,8 @@ app.post('/api/combine', async (req, res) => {
renderSub(c, parseInt(c.width) || 640, parseInt(c.height) || 420)
));
let W, H;
const canvas = createCanvas(1, 1);
const ctx = canvas.getContext('2d');
// 基础布局(不含大标题/共享图例):计算子图单元格位置
let W, H, cells = [];
if (direction === 'grid') {
// 多行多列网格:所有子图统一 contain 到最大单元格,按列填充
const rows = Math.ceil(subs.length / cols);
@@ -1244,53 +1310,122 @@ app.post('/api/combine', async (req, res) => {
const cellH = Math.max(...subs.map(s => s.h));
W = cols * cellW + (cols - 1) * gap;
H = rows * cellH + (rows - 1) * gap;
canvas.width = W * pixelRatio;
canvas.height = H * pixelRatio;
ctx.fillStyle = background;
ctx.fillRect(0, 0, W * pixelRatio, H * pixelRatio);
subs.forEach((s, i) => {
const r = Math.floor(i / cols), c = i % cols;
const scale = Math.min(cellW / s.w, cellH / s.h);
const dw = Math.round(s.w * scale), dh = Math.round(s.h * scale);
const x = c * (cellW + gap) + Math.round((cellW - dw) / 2);
const y = r * (cellH + gap) + Math.round((cellH - dh) / 2);
ctx.drawImage(s.img, x * pixelRatio, y * pixelRatio, dw * pixelRatio, dh * pixelRatio);
cells.push({
img: s.img,
x: c * (cellW + gap) + Math.round((cellW - dw) / 2),
y: r * (cellH + gap) + Math.round((cellH - dh) / 2),
w: dw, h: dh
});
} else {
let scaled;
if (direction === 'vertical') {
});
} else if (direction === 'vertical') {
// 竖排(上下):等宽对齐
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)) }));
let y = 0;
cells = subs.map(s => {
const h = Math.round(s.h * (tw / s.w));
const cell = { img: s.img, x: 0, y, w: tw, h };
y += h + gap;
return cell;
});
W = tw;
H = scaled.reduce((sum, s) => sum + s.h, 0) + gap * (scaled.length - 1);
H = y - gap;
} else {
// 横排(左右):等高对齐
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);
let x = 0;
cells = subs.map(s => {
const w = Math.round(s.w * (th / s.h));
const cell = { img: s.img, x, y: 0, w, h: th };
x += w + gap;
return cell;
});
W = x - gap;
H = th;
}
canvas.width = W * pixelRatio;
canvas.height = H * pixelRatio;
ctx.fillStyle = background;
ctx.fillRect(0, 0, W * pixelRatio, H * pixelRatio);
if (direction === 'vertical') {
// 5 参数形式:drawImage(img, dx, dy, dw, dh),源整图缩放到目标矩形
let y = 0;
scaled.forEach(s => {
ctx.drawImage(s.img, 0, y * pixelRatio, s.w * pixelRatio, s.h * pixelRatio);
y += s.h + gap;
});
} else {
let x = 0;
scaled.forEach(s => {
ctx.drawImage(s.img, x * pixelRatio, 0, s.w * pixelRatio, s.h * pixelRatio);
x += s.w + gap;
// 共享图例:按宽度换行计算
const legendFont = 13, swatch = 14, sw = 5, ig = 18, lineH = legendFont + 10;
let legendRows = [], legendH = 0;
if (sharedLegend) {
const items = combineLegendItems(charts);
const tmp = createCanvas(10, 10);
const tctx = tmp.getContext('2d');
tctx.font = `${legendFont}px "Noto Sans CJK SC", sans-serif`;
const maxW = W - 20;
let cur = [], curW = 0;
items.forEach(it => {
const itemW = swatch + sw + tctx.measureText(it.name).width;
if (cur.length && curW + ig + itemW > maxW) { legendRows.push(cur); cur = []; curW = 0; }
cur.push(it);
curW += itemW + (cur.length > 1 ? ig : 0);
});
if (cur.length) legendRows.push(cur);
legendH = legendRows.length * lineH + 8;
}
// 大标题区高度
const titleFont = 34;
const titleH = bigTitle ? titleFont + 26 : 0;
const topOffset = titleH + (legendMode === 'shared-top' ? legendH + 12 : 0);
const bottomOffset = legendMode === 'shared-bottom' ? legendH + 12 : 0;
const totalH = H + topOffset + bottomOffset;
// 创建最终画布(统一 scale,全部用逻辑坐标绘制)
const canvas = createCanvas(W * pixelRatio, totalH * pixelRatio);
const ctx = canvas.getContext('2d');
ctx.scale(pixelRatio, pixelRatio);
// 背景
ctx.fillStyle = background;
ctx.fillRect(0, 0, W, totalH);
// 大标题
if (bigTitle) {
ctx.font = `bold ${titleFont}px "Noto Sans CJK SC", sans-serif`;
ctx.fillStyle = textColor;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(bigTitle, W / 2, (titleH - 26) / 2 + 2);
}
// 绘制图例行(topY 为图例区顶部逻辑坐标)
const drawLegendBlock = (topY) => {
legendRows.forEach((row, ri) => {
ctx.font = `${legendFont}px "Noto Sans CJK SC", sans-serif`;
let totalW = 0;
row.forEach(it => { totalW += swatch + sw + ctx.measureText(it.name).width + ig; });
totalW -= ig;
let cx = (W - totalW) / 2;
row.forEach(it => {
ctx.fillStyle = it.color;
ctx.fillRect(cx, topY + ri * lineH + (legendFont - swatch) / 2 + 2, swatch, swatch);
ctx.fillStyle = textColor;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.font = `${legendFont}px "Noto Sans CJK SC", sans-serif`;
ctx.fillText(it.name, cx + swatch + sw, topY + ri * lineH + legendFont / 2 + 2);
cx += swatch + sw + ctx.measureText(it.name).width + ig;
});
});
};
// 顶部共享图例
if (sharedLegend && legendMode === 'shared-top') {
drawLegendBlock(titleH + 4);
}
// 子图(整体下移 topOffset
cells.forEach(cell => {
ctx.drawImage(cell.img, cell.x, cell.y + topOffset, cell.w, cell.h);
});
// 底部共享图例
if (sharedLegend && legendMode === 'shared-bottom') {
drawLegendBlock(topOffset + H + 8);
}
const buffer = canvas.toBuffer('image/png');
+36
View File
@@ -494,6 +494,42 @@ body {
background: #f8fafc;
}
/* 多图合并 - 每系列配置(类型/坐标轴) */
.combine-series-cfg {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
}
.combine-series-row {
display: flex;
align-items: center;
gap: 6px;
background: #fff;
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 8px;
}
.combine-series-name {
flex: 1;
font-size: 0.8rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.combine-series-row select {
max-width: 96px;
padding: 3px 6px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.78rem;
background: white;
}
.combine-chart-card h3 {
font-size: 0.95rem;
margin: 0;