Compare commits

..
5 Commits
Author SHA1 Message Date
hz4th_coder 7a03ecc063 feat: 新增饼图/雷达图图表类型
- 单图模式图表类型新增:饼图(环形占比,第一列=名称/第一系列=数值)、雷达图(多维度对比,第一列=维度/每系列=多边形)
- 多图合并的每张图也可选饼图/雷达图
- 后端 /api/chart 与 /api/combine 的 chartType 支持 pie/radar
- 饼图/雷达图自动隐藏坐标轴/网格/双轴等不适用配置
2026-08-19 17:12:18 +08:00
hz4th_coder d7c7c08407 fix: keepalive 保活脚本改用 node 绝对路径
cron 环境 PATH 不含 nvm 目录,nohup node 找不到命令导致服务一直拉不起来
(HTTP=000)。改用 $HOME/.nvm/.../bin/node 绝对路径,模拟 cron 最小环境
验证可正常拉起。
2026-08-19 16:39:45 +08:00
hz4th_coder 6b5223daec feat: 多图合并支持多行多列网格排布 + 统一'多图合并'文案
- 合并模式新增'多行多列'排布(radio),可选每行列数(gridCols),
  子图按最大单元格 contain 居中排列,列数变化实时重排
- 后端 /api/combine 支持 direction=grid + cols 参数
- 页面所有'双图合并'文案统一改为'多图合并'
2026-08-19 13:39:14 +08:00
hz4th_coder e56c6cb79f feat: 双图合并升级为多图合并 + 数据输入框加宽
- 合并模式默认2张图,新增'+添加图表'按钮可不断添加,每张图可独立删除
- 每张图卡片:标题/类型/主题/显示选项/数据,动态渲染(combineCharts 数组驱动)
- 合并逻辑支持 N 张图:横排等高依次排列、竖排等宽依次堆叠
- 后端 /api/combine 支持 charts 数组(N 张),兼容 chart1+chart2
- 修复合并图数据输入框过窄:.config-group textarea 统一 width:100%(与设置区一致)
2026-08-19 13:30:35 +08:00
hz4th_coder 576024af76 feat: 双Y轴左右轴可独立命名 + 不同轴可设不同图表类型
- 双Y轴:左右两个轴都可单独命名(左轴名称/右轴名称输入框)
- 修复系列类型下拉不生效的 bug(seriesTypeOverrides 只写不读),
  现在每个系列可独立设置柱状/折线,结合轴分配实现'左轴柱状+右轴折线'
- 后端 /api/chart 新增 leftAxisName + seriesTypes 参数(POST/GET 均可)
2026-08-19 13:19:28 +08:00
7 changed files with 589 additions and 251 deletions
+23 -11
View File
@@ -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 |
@@ -32,7 +32,7 @@ Content-Type: application/json
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|:----:|--------|------|
| `data` | string | ✅ | — | CSV 格式数据,`\n` 换行,第一行表头,第一列横坐标 |
| `chartType` | string | — | `bar` | 图表类型:`bar` / `line` / `bar-line` |
| `chartType` | string | — | `bar` | 图表类型:`bar` / `line` / `bar-line` / `pie` / `radar` |
| `title` | string | — | `""` | 图表标题 |
| `theme` | string | — | `default` | 主题风格:`default` / `dark` / `macarons` / `gradient` / `retro` |
| `showLegend` | boolean | — | `true` | 显示图例 |
@@ -47,7 +47,9 @@ Content-Type: application/json
| `splitStyle` | string | — | `"solid"` | 分割线样式:`solid` / `dashed` / `dotted` |
| `dualYAxis` | boolean | — | `false` | 启用双Y轴(左右量度不同) |
| `rightAxisSeries` | array | — | `null` | 右轴系列名列表,如 `["利润"]`(未列出的系列用左轴) |
| `rightAxisName` | string | — | `""` | 轴名称 |
| `leftAxisName` | string | — | `""` | 轴名称(如 `"销售额(元)"` |
| `rightAxisName` | string | — | `""` | 右轴名称(如 `"增长率(%)"` |
| `seriesTypes` | array | — | `null` | 每系列图表类型,按系列顺序对应,如 `["bar","line"]``bar`/`line`/`auto` |
| `width` | number | — | `800` | 图片宽度(px |
| `height` | number | — | `500` | 图片高度(px |
| `pixelRatio` | number | — | `2` | 像素倍率(越大越清晰) |
@@ -155,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 图片。
### 请求
@@ -170,22 +172,32 @@ Content-Type: application/json
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `chart1` | object | ✅ | 第一张图配置(`/api/chart` 参数:data/chartType/title/theme 等 |
| `chart2` | object | | 第二张图配置(同 `/api/chart` 参数) |
| `direction` | string | 否 | `horizontal` 横排(默认)/ `vertical` 竖排 |
| `gap` | number | 否 | 图间距(默认 24px |
| `charts` | array | ✅ | 图表配置数组(N 张),每项`/api/chart` 参数:data/chartType/title/theme 等 |
| `direction` | string | | `horizontal` 横排(默认)/ `vertical` 竖排 / `grid` 多行多列 |
| `cols` | number | 否 | 多行多列时的每行列数(默认 2,仅 `grid` 生效) |
| `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`
> 兼容旧参数:`chart1` + `chart2` 仍可传(等价于 `charts: [chart1, chart2]`
每个子图配置支持:`data`CSV)、`chartType`bar/line/bar-line/pie/radar)、`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年销售",
+8 -6
View File
@@ -11,7 +11,9 @@
- **柱状图** - 支持单系列/多系列对比
- **折线图** - 支持平滑曲线、面积填充
- **混合图** - 柱状图+折线图组合展示
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,系列可指定左轴/右轴(含右轴名称
- **饼图** - 环形占比展示(第一列=名称,第一系列=数值
- **雷达图** - 多维度对比(第一列=维度,每个系列=一个多边形)
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线)
### 📋 表格功能
- **表格图片生成** - 根据数据生成精美的表格图片
@@ -30,10 +32,10 @@
- 可自定义分割线样式(实线/虚线/点线)
- 自动标注左右区域标签
### 🖼️ 图合并
-张图表合并到一张图片中,适合对比/汇总场景
- 支持**横排(左右并排)** 和 **竖排(上下堆叠)**,一键切换实时预览
- 张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠
### 🖼️ 图合并
-张图表合并到一张图片中,适合对比/汇总场景,**默认2张,可一键不断添加**(支持删除)
- 支持**横排(单行)**、**竖排(单列)**、**多行多列网格**(可设每行列数),一键切换实时预览
- 张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠
- 支持导出 PNG
### 📡 API 接口
@@ -41,7 +43,7 @@
- **GET /api/chart** - URL 参数生成图表(简单场景)
- **POST /api/table** - JSON 请求体生成表格图片
- **GET /api/table** - URL 参数生成表格图片
- **POST /api/combine** - 图合并(横排/竖排)生成一张图片
- **POST /api/combine** - 图合并(横排/竖排)生成一张图片
- 返回 PNG 图片,支持自定义分辨率和像素倍率
### 📥 导出功能
+322 -64
View File
@@ -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();
@@ -161,6 +157,91 @@ function initChart() {
});
}
// ===== 饼图/雷达图 option 构建 =====
function buildPieRadarOption(chartType) {
if (!parsedData || parsedData.seriesNames.length === 0) return null;
const title = document.getElementById('chartTitle').value;
const theme = document.getElementById('themeStyle').value;
const showLegend = document.getElementById('showLegend').checked;
const showLabel = document.getElementById('showLabel').checked;
const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff';
const textColor = theme === 'dark' ? '#e0e0e0' : '#333333';
const palette = colorPalettes[theme] || colorPalettes.default;
const colors = seriesOrder.map((origIdx, di) => seriesColors[origIdx] || palette[di % palette.length]);
const baseTitle = title ? {
text: title, left: 'center', top: 10,
textStyle: { color: textColor, fontSize: 18, fontWeight: 600 }
} : undefined;
if (chartType === 'pie') {
// 饼图:第一列=名称,第一个系列=数值
const sName = parsedData.seriesNames[0];
const data = parsedData.seriesData[sName];
const pieData = parsedData.categories.map((c, i) => ({ name: c, value: data[i] || 0 }));
return {
backgroundColor: bgColor,
title: baseTitle,
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: showLegend ? {
orient: 'vertical', left: 'left', top: title ? 50 : 20,
textStyle: { color: textColor, fontSize: 12 }
} : { show: false },
color: colors,
series: [{
type: 'pie',
radius: ['38%', '68%'],
center: ['52%', '55%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: bgColor, borderWidth: 2 },
label: { show: showLabel, formatter: '{b}: {d}%', color: textColor },
labelLine: { show: showLabel },
data: pieData
}]
};
} else {
// 雷达图:第一列=维度名,每个系列=一个雷达多边形
const indicator = parsedData.categories.map(c => {
let max = 0;
parsedData.seriesNames.forEach(n => {
parsedData.seriesData[n].forEach(v => { if (v > max) max = v; });
});
return { name: c, max: Math.ceil(max * 1.2) || 100 };
});
return {
backgroundColor: bgColor,
title: baseTitle,
tooltip: { trigger: 'item' },
legend: showLegend ? {
orient: 'horizontal', left: 'center', top: title ? 48 : 15,
textStyle: { color: textColor, fontSize: 12 }
} : { show: false },
color: colors,
radar: {
indicator,
radius: '62%',
center: ['50%', '55%'],
splitNumber: 5,
axisName: { color: textColor, fontSize: 12 },
splitLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } },
splitArea: {
areaStyle: {
color: theme === 'dark' ? ['rgba(79,195,247,0.03)', 'rgba(79,195,247,0.06)'] : ['rgba(79,70,229,0.03)', 'rgba(79,70,229,0.06)']
}
},
axisLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } }
},
series: parsedData.seriesNames.map(n => ({
type: 'radar',
name: n,
data: [{ value: parsedData.seriesData[n], name: n }],
symbolSize: 4,
areaStyle: { opacity: 0.15 }
}))
};
}
}
// ===== 更新图表 =====
function updateChart() {
if (!parsedData || !chartInstance) return;
@@ -175,8 +256,19 @@ function updateChart() {
const smoothLine = document.getElementById('smoothLine').checked;
const enableSplit = document.getElementById('enableSplit').checked;
const dualAxis = document.getElementById('dualAxis').checked;
const leftAxisName = document.getElementById('leftAxisName').value;
const rightAxisName = document.getElementById('rightAxisName').value;
// 饼图/雷达图:走专用构建逻辑(无坐标轴/网格,不支持双轴/堆叠/分割)
if (chartType === 'pie' || chartType === 'radar') {
const option = buildPieRadarOption(chartType);
if (option) {
chartInstance.setOption(option, true);
refreshPreview();
}
return;
}
// 显示/隐藏分割配置
document.getElementById('splitConfig').style.display = enableSplit ? 'block' : 'none';
@@ -191,10 +283,17 @@ function updateChart() {
const name = parsedData.seriesNames[origIdx];
const data = parsedData.seriesData[name];
const color = seriesColors[origIdx] || palette[origIdx % palette.length];
let type = chartType === 'bar-line'
? (displayIdx % 2 === 0 ? 'bar' : 'line')
: chartType;
// 系列类型:优先用系列配置的独立覆盖(支持不同轴不同图表类型)
const override = window.seriesTypeOverrides && window.seriesTypeOverrides[origIdx];
let type;
if (override && override !== 'auto') {
type = override;
} else if (chartType === 'bar-line') {
type = displayIdx % 2 === 0 ? 'bar' : 'line';
} else {
type = chartType;
}
const seriesItem = {
name: name,
@@ -391,7 +490,7 @@ function updateChart() {
yAxis: dualAxis ? [
{
type: 'value',
name: '左轴',
name: leftAxisName || '左轴',
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] },
axisLine: { show: false },
axisTick: { show: false },
@@ -1012,7 +1111,7 @@ function quickSwitchTheme(theme) {
document.getElementById('tableTheme').value = theme;
updateTable();
}
// 图合并模式:仅切换按钮高亮,不联动(两图各自独立主题)
// 图合并模式:仅切换按钮高亮,不联动(各图独立主题)
}
// ===== 同步风格按钮状态(下拉框变更时) =====
@@ -1035,7 +1134,7 @@ document.addEventListener('DOMContentLoaded', () => {
initThemeButtons();
});
// ===== 图合并 =====
// ===== 图合并 =====
let combinedCanvas = null;
// 构建单个子图的 echarts option(不依赖全局状态,双图模式专用)
@@ -1054,6 +1153,66 @@ function buildCombineChartOption(dataText, cfg) {
const axisLineColor = theme === 'dark' ? '#444' : '#ddd';
const palette = colorPalettes[theme] || colorPalettes.default;
// 饼图/雷达图:专用构建逻辑
if (chartType === 'pie' || chartType === 'radar') {
if (chartType === 'pie') {
const sName = parsed.seriesNames[0];
const data = parsed.seriesData[sName];
const pieData = parsed.categories.map((c, i) => ({ name: c, value: data[i] || 0 }));
return {
backgroundColor: bgColor,
title: title ? { text: title, left: 'center', top: 10, textStyle: { color: textColor, fontSize: 16, fontWeight: 600 } } : undefined,
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: showLegend ? { orient: 'vertical', left: 'left', top: title ? 40 : 10, textStyle: { color: textColor, fontSize: 12 } } : { show: false },
color: palette,
series: [{
type: 'pie',
radius: ['38%', '68%'],
center: ['55%', '55%'],
itemStyle: { borderRadius: 6, borderColor: bgColor, borderWidth: 2 },
label: { show: showLabel, formatter: '{b}: {d}%', color: textColor },
labelLine: { show: showLabel },
data: pieData
}],
animation: false
};
} else {
const indicator = parsed.categories.map(c => {
let max = 0;
parsed.seriesNames.forEach(n => { parsed.seriesData[n].forEach(v => { if (v > max) max = v; }); });
return { name: c, max: Math.ceil(max * 1.2) || 100 };
});
return {
backgroundColor: bgColor,
title: title ? { text: title, left: 'center', top: 10, textStyle: { color: textColor, fontSize: 16, fontWeight: 600 } } : undefined,
tooltip: { trigger: 'item' },
legend: showLegend ? { orient: 'horizontal', left: 'center', top: title ? 38 : 10, textStyle: { color: textColor, fontSize: 12 } } : { show: false },
color: palette,
radar: {
indicator,
radius: '62%',
center: ['50%', '55%'],
splitNumber: 5,
axisName: { color: textColor, fontSize: 11 },
splitLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } },
splitArea: {
areaStyle: {
color: theme === 'dark' ? ['rgba(79,195,247,0.03)', 'rgba(79,195,247,0.06)'] : ['rgba(79,70,229,0.03)', 'rgba(79,70,229,0.06)']
}
},
axisLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } }
},
series: parsed.seriesNames.map(n => ({
type: 'radar', name: n,
data: [{ value: parsed.seriesData[n], name: n }],
symbolSize: 4,
areaStyle: { opacity: 0.15 }
})),
animation: false
};
}
}
const series = parsed.seriesNames.map((name, idx) => {
const color = palette[idx % palette.length];
let type = chartType === 'bar-line' ? (idx % 2 === 0 ? 'bar' : 'line') : chartType;
@@ -1156,30 +1315,113 @@ 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', '柱状图+折线图混合'], ['pie', '饼图'], ['radar', '雷达图']];
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function renderCombineCharts() {
const container = document.getElementById('combineChartsContainer');
if (!container) return;
container.innerHTML = combineCharts.map((c, i) => `
<div class="combine-chart-card" id="combineCard${i}">
<div class="combine-card-head">
<h3>📈 图${i + 1}</h3>
<button class="btn btn-remove" onclick="removeCombineChart(${i})" title="删除此图">✕</button>
</div>
<div class="config-group">
<label>标题</label>
<input type="text" value="${escHtml(c.title)}" placeholder="图${i + 1}标题" oninput="updateCombineChart(${i},'title',this.value)">
</div>
<div class="config-group">
<label>图表类型</label>
<select onchange="updateCombineChart(${i},'type',this.value)">
${COMBINE_TYPES.map(([v, l]) => `<option value="${v}" ${c.type === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
</div>
<div class="config-group">
<label>主题风格</label>
<select onchange="updateCombineChart(${i},'theme',this.value)">
${COMBINE_THEMES.map(([v, l]) => `<option value="${v}" ${c.theme === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
</div>
<div class="config-group">
<label>显示选项</label>
<div class="checkbox-group">
<label><input type="checkbox" ${c.legend ? 'checked' : ''} onchange="updateCombineChart(${i},'legend',this.checked)"> 图例</label>
<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>
</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>
</div>
</div>
`).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 onCombineGridToggle() {
const grid = document.querySelector('input[name="combineDirection"]:checked').value === 'grid';
document.getElementById('gridColsGroup').style.display = grid ? 'block' : 'none';
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 = '<div class="placeholder"><p>⏳</p><p>正在合并...</p></div>';
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;`;
@@ -1189,9 +1431,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;
@@ -1202,41 +1442,59 @@ 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;
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;
}
Promise.all(validCharts.map(c => {
const m = measureCombineChart(c.data);
return renderOne(c, m.w, m.h);
})).then(canvases => {
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);
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);
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);
});
} else {
ctx.drawImage(c1, 0, 0, r1.w, r1.h);
ctx.drawImage(c2, r1.w + gap, 0, r2.w, r2.h);
let W, H, scaled;
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) }));
W = tw;
H = scaled.reduce((s, x) => s + x.h, 0) + gap * (scaled.length - 1);
} 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);
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; });
}
}
combinedCanvas = canvas;
+22 -117
View File
@@ -23,7 +23,7 @@
<div class="mode-switch">
<button class="mode-btn active" id="btnChartMode" onclick="switchMode('chart')">📈 图表模式</button>
<button class="mode-btn" id="btnTableMode" onclick="switchMode('table')">📋 表格模式</button>
<button class="mode-btn" id="btnCombineMode" onclick="switchMode('combine')">🖼️ 图合并</button>
<button class="mode-btn" id="btnCombineMode" onclick="switchMode('combine')">🖼️ 图合并</button>
</div>
</div>
@@ -56,6 +56,8 @@ C, 20, 30, 40</pre>
<option value="bar">柱状图</option>
<option value="line">折线图</option>
<option value="bar-line">柱状图+折线图混合</option>
<option value="pie">饼图</option>
<option value="radar">雷达图</option>
</select>
</div>
@@ -105,9 +107,13 @@ C, 20, 30, 40</pre>
</div>
<div class="config-group" id="rightAxisGroup" style="display:none;">
<label>右轴名称(量度不同)</label>
<input type="text" id="rightAxisName" placeholder="如:利润(万元)" oninput="updateChart()">
<p class="hint-text">在下方「系列配置」中可指定每个系列用左轴/右轴</p>
<label>右轴名称(量度不同)</label>
<div class="res-input-row">
<input type="text" id="leftAxisName" placeholder="左轴,如:销售额" oninput="updateChart()">
<span class="res-times">/</span>
<input type="text" id="rightAxisName" placeholder="右轴,如:增长率(%)" oninput="updateChart()">
</div>
<p class="hint-text">在下方「系列配置」中可指定每个系列用左轴/右轴及图表类型(柱状/折线)</p>
</div>
</div>
@@ -268,131 +274,30 @@ C, 20, 30, 40</pre>
</div>
</div>
<!-- 图合并配置区 -->
<!-- 图合并配置区 -->
<div class="panel-section" id="combineConfigSection" style="display:none;">
<h2>🖼️ 图合并配置</h2>
<h2>🖼️ 图合并配置</h2>
<div class="config-group">
<label>排列方</label>
<label>排列方</label>
<div class="combine-direction">
<label class="dir-option"><input type="radio" name="combineDirection" value="horizontal" checked onchange="generateCombine()"> ⇄ 横排(左右</label>
<label class="dir-option"><input type="radio" name="combineDirection" value="vertical" onchange="generateCombine()"> ⇅ 竖排(上下</label>
<label class="dir-option"><input type="radio" name="combineDirection" value="horizontal" checked onchange="generateCombine()"> ⇄ 横排(单行</label>
<label class="dir-option"><input type="radio" name="combineDirection" value="vertical" onchange="generateCombine()"> ⇅ 竖排(单列</label>
<label class="dir-option"><input type="radio" name="combineDirection" value="grid" onchange="onCombineGridToggle()"> ▦ 多行多列</label>
</div>
</div>
<div class="combine-chart-card">
<h3>📈 图1</h3>
<div class="config-group">
<label>标题</label>
<input type="text" id="combineTitle1" placeholder="图1标题" oninput="generateCombine()">
</div>
<div class="config-group">
<label>图表类型</label>
<select id="combineType1" onchange="generateCombine()">
<option value="bar">柱状图</option>
<option value="line">折线图</option>
<option value="bar-line">柱状图+折线图混合</option>
</select>
</div>
<div class="config-group">
<label>主题风格</label>
<select id="combineTheme1" onchange="generateCombine()">
<option value="default">默认</option>
<option value="dark">深色</option>
<option value="macarons">马卡龙</option>
<option value="gradient">渐变</option>
<option value="retro">复古</option>
<option value="ocean">海洋</option>
<option value="forest">森林</option>
<option value="sunset">日落</option>
<option value="lavender">薰衣草</option>
<option value="minimal">极简</option>
<option value="cherry">樱花</option>
<option value="midnight">午夜</option>
<option value="gold">金色</option>
<option value="coral">珊瑚</option>
<option value="mint">薄荷</option>
<option value="slate">石板灰</option>
<option value="sky">天空蓝</option>
<option value="rose">玫瑰红</option>
<option value="amber">琥珀黄</option>
<option value="emerald">翡翠绿</option>
<option value="indigo">靛蓝色</option>
<option value="stone">石灰白</option>
</select>
</div>
<div class="config-group">
<label>显示选项</label>
<div class="checkbox-group">
<label><input type="checkbox" id="combineLegend1" checked onchange="generateCombine()"> 图例</label>
<label><input type="checkbox" id="combineGrid1" checked onchange="generateCombine()"> 网格线</label>
<label><input type="checkbox" id="combineLabel1" onchange="generateCombine()"> 数据标签</label>
<label><input type="checkbox" id="combineStack1" onchange="generateCombine()"> 堆叠</label>
</div>
</div>
<div class="config-group">
<label>数据</label>
<textarea id="combineData1" rows="5" placeholder="图1的CSV数据..."></textarea>
</div>
<div class="config-group" id="gridColsGroup" style="display:none;">
<label>每行列数</label>
<input type="number" id="gridCols" value="2" min="1" max="6" oninput="generateCombine()">
</div>
<div class="combine-chart-card">
<h3>📈 图2</h3>
<div class="config-group">
<label>标题</label>
<input type="text" id="combineTitle2" placeholder="图2标题" oninput="generateCombine()">
</div>
<div class="config-group">
<label>图表类型</label>
<select id="combineType2" onchange="generateCombine()">
<option value="bar">柱状图</option>
<option value="line">折线图</option>
<option value="bar-line">柱状图+折线图混合</option>
</select>
</div>
<div class="config-group">
<label>主题风格</label>
<select id="combineTheme2" onchange="generateCombine()">
<option value="default">默认</option>
<option value="dark">深色</option>
<option value="macarons">马卡龙</option>
<option value="gradient">渐变</option>
<option value="retro">复古</option>
<option value="ocean">海洋</option>
<option value="forest">森林</option>
<option value="sunset">日落</option>
<option value="lavender">薰衣草</option>
<option value="minimal">极简</option>
<option value="cherry">樱花</option>
<option value="midnight">午夜</option>
<option value="gold">金色</option>
<option value="coral">珊瑚</option>
<option value="mint">薄荷</option>
<option value="slate">石板灰</option>
<option value="sky">天空蓝</option>
<option value="rose">玫瑰红</option>
<option value="amber">琥珀黄</option>
<option value="emerald">翡翠绿</option>
<option value="indigo">靛蓝色</option>
<option value="stone">石灰白</option>
</select>
</div>
<div class="config-group">
<label>显示选项</label>
<div class="checkbox-group">
<label><input type="checkbox" id="combineLegend2" checked onchange="generateCombine()"> 图例</label>
<label><input type="checkbox" id="combineGrid2" checked onchange="generateCombine()"> 网格线</label>
<label><input type="checkbox" id="combineLabel2" onchange="generateCombine()"> 数据标签</label>
<label><input type="checkbox" id="combineStack2" onchange="generateCombine()"> 堆叠</label>
</div>
</div>
<div class="config-group">
<label>数据</label>
<textarea id="combineData2" rows="5" placeholder="图2的CSV数据..."></textarea>
</div>
<div id="combineChartsContainer">
<!-- 图表卡片由 JS 动态渲染 -->
</div>
<div class="btn-group">
<button class="btn btn-secondary" onclick="addCombineChart()"> 添加图表</button>
<button class="btn btn-primary" onclick="generateCombine()">🖼️ 生成合并图</button>
<button class="btn btn-success" onclick="exportImage('png')">📥 下载图片</button>
</div>
+11 -1
View File
@@ -6,6 +6,12 @@ PORT=16016
APP_DIR="/home/openclaw/.openclaw/workspace-hz4th_coder/works/data-chart-tool"
LOG_FILE="$APP_DIR/logs/server.log"
# node 绝对路径(cron 环境 PATH 不含 nvm,必须用全路径)
NODE_BIN="$HOME/.nvm/versions/node/v24.15.0/bin/node"
if [ ! -x "$NODE_BIN" ]; then
NODE_BIN="$(command -v node 2>/dev/null)"
fi
# 检查端口是否被占用
if ss -tlnp | grep -q ":$PORT "; then
# 检查是不是 node 进程
@@ -28,9 +34,13 @@ if ss -tlnp | grep -q ":$PORT "; then
fi
# 启动 node 服务
if [ -z "$NODE_BIN" ] || [ ! -x "$NODE_BIN" ]; then
echo "[$(date)] ERROR: node 未找到,无法启动"
exit 1
fi
echo "[$(date)] Starting data-chart-tool..."
cd "$APP_DIR"
nohup node server.js >> "$LOG_FILE" 2>&1 &
nohup "$NODE_BIN" server.js >> "$LOG_FILE" 2>&1 &
echo "[$(date)] Started with PID $!"
# 等待启动
+165 -49
View File
@@ -116,7 +116,9 @@ function buildChartOption(params) {
seriesColors: customColors = null,
dualYAxis = false,
rightAxisSeries = null,
rightAxisName = ''
leftAxisName = '',
rightAxisName = '',
seriesTypes = null
} = params;
const parsedData = parseData(data);
@@ -128,6 +130,72 @@ function buildChartOption(params) {
const textColor = theme === 'dark' ? '#e0e0e0' : '#333333';
const axisLineColor = theme === 'dark' ? '#444' : '#ddd';
// 饼图/雷达图:专用构建逻辑(无坐标轴/网格)
if (chartType === 'pie' || chartType === 'radar') {
const bgC = theme === 'dark' ? '#1a1a2e' : '#ffffff';
const txtC = theme === 'dark' ? '#e0e0e0' : '#333333';
const tTitle = title ? { text: title, left: 'center', top: 15, textStyle: { color: txtC, fontSize: 18, fontWeight: 600 } } : undefined;
if (chartType === 'pie') {
// 饼图:第一列=名称,第一个系列=数值
const sName = parsedData.seriesNames[0];
const data = parsedData.seriesData[sName];
const pieData = parsedData.categories.map((c, i) => ({ name: c, value: data[i] || 0 }));
return {
backgroundColor: bgC,
title: tTitle,
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: showLegend ? { orient: 'vertical', left: 'left', top: title ? 50 : 20, textStyle: { color: txtC, fontSize: 12 } } : { show: false },
color: seriesColorsArr,
series: [{
type: 'pie',
radius: ['38%', '68%'],
center: ['52%', '55%'],
itemStyle: { borderRadius: 6, borderColor: bgC, borderWidth: 2 },
label: { show: showLabel, formatter: '{b}: {d}%', color: txtC },
labelLine: { show: showLabel },
data: pieData
}],
animation: false
};
} else {
// 雷达图:第一列=维度名,每个系列=一个雷达多边形
const indicator = parsedData.categories.map(c => {
let max = 0;
parsedData.seriesNames.forEach(n => { parsedData.seriesData[n].forEach(v => { if (v > max) max = v; }); });
return { name: c, max: Math.ceil(max * 1.2) || 100 };
});
return {
backgroundColor: bgC,
title: tTitle,
tooltip: { trigger: 'item' },
legend: showLegend ? { orient: 'horizontal', left: 'center', top: title ? 48 : 15, textStyle: { color: txtC, fontSize: 12 } } : { show: false },
color: seriesColorsArr,
radar: {
indicator,
radius: '62%',
center: ['50%', '55%'],
splitNumber: 5,
axisName: { color: txtC, fontSize: 12 },
splitLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } },
splitArea: {
areaStyle: {
color: theme === 'dark' ? ['rgba(79,195,247,0.03)', 'rgba(79,195,247,0.06)'] : ['rgba(79,70,229,0.03)', 'rgba(79,70,229,0.06)']
}
},
axisLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } }
},
series: parsedData.seriesNames.map(n => ({
type: 'radar',
name: n,
data: [{ value: parsedData.seriesData[n], name: n }],
symbolSize: 4,
areaStyle: { opacity: 0.15 }
})),
animation: false
};
}
}
// 构建系列
const series = parsedData.seriesNames.map((name, idx) => {
const dataArr = parsedData.seriesData[name];
@@ -137,6 +205,11 @@ function buildChartOption(params) {
? (idx % 2 === 0 ? 'bar' : 'line')
: chartType;
// 每系列独立类型覆盖(seriesTypes 数组按系列顺序对应)
if (Array.isArray(seriesTypes) && seriesTypes[idx] && seriesTypes[idx] !== 'auto') {
type = seriesTypes[idx];
}
const seriesItem = {
name: name,
type: type,
@@ -280,7 +353,7 @@ function buildChartOption(params) {
yAxis: dualYAxis ? [
{
type: 'value',
name: '左轴',
name: leftAxisName || '左轴',
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] },
axisLine: { show: false },
axisTick: { show: false },
@@ -402,7 +475,9 @@ app.get('/api/chart', (req, res) => {
splitStyle: req.query.splitStyle || 'solid',
dualYAxis: req.query.dualYAxis === 'true',
rightAxisSeries: req.query.rightAxisSeries ? req.query.rightAxisSeries.split(',') : null,
leftAxisName: req.query.leftAxisName || '',
rightAxisName: req.query.rightAxisName || '',
seriesTypes: req.query.seriesTypes ? req.query.seriesTypes.split(',') : null,
width: parseInt(req.query.width) || 800,
height: parseInt(req.query.height) || 500,
format: req.query.format || 'png',
@@ -919,7 +994,7 @@ app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
service: 'data-chart-tool',
version: '1.11.1',
version: '1.14.0',
endpoints: {
'POST /api/chart': '生成图表图片(JSON body',
'GET /api/chart': '生成图表图片(URL 参数)',
@@ -934,7 +1009,7 @@ app.get('/api/health', (req, res) => {
app.get('/api/docs', (req, res) => {
res.json({
name: '数据可视化图表生成器 API',
version: '1.11.1',
version: '1.14.0',
endpoints: [
{
method: 'POST',
@@ -943,7 +1018,7 @@ app.get('/api/docs', (req, res) => {
'Content-Type': 'application/json',
params: {
data: { type: 'string', required: true, description: 'CSV 格式数据(第一行表头,第一列横坐标)' },
chartType: { type: 'string', default: 'bar', options: ['bar', 'line', 'bar-line'], description: '图表类型' },
chartType: { type: 'string', default: 'bar', options: ['bar', 'line', 'bar-line', 'pie', 'radar'], description: '图表类型' },
title: { type: 'string', default: '', description: '图表标题' },
theme: { type: 'string', default: 'default', options: ['default', 'dark', 'macarons', 'gradient', 'retro', 'ocean', 'forest', 'sunset', 'lavender', 'minimal', 'cherry', 'midnight', 'gold', 'coral', 'mint'], description: '主题风格' },
showLegend: { type: 'boolean', default: true, description: '是否显示图例' },
@@ -962,7 +1037,9 @@ app.get('/api/docs', (req, res) => {
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' },
dualYAxis: { type: 'boolean', default: false, description: '是否启用双Y轴(左右量度不同)' },
rightAxisSeries: { type: 'array', default: null, description: '右轴系列名列表(如 ["利润"],指定哪些系列用右轴)' },
rightAxisName: { type: 'string', default: '', description: '轴名称' }
leftAxisName: { type: 'string', default: '', description: '轴名称' },
rightAxisName: { type: 'string', default: '', description: '右轴名称' },
seriesTypes: { type: 'array', default: null, description: '每系列图表类型(如 ["bar","line"],按系列顺序对应,bar/line/auto' }
},
returns: 'image/png',
example: {
@@ -1044,13 +1121,13 @@ 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 参数)' },
direction: { type: 'string', default: 'horizontal', options: ['horizontal', 'vertical'], description: '排列方向:horizontal 横排(左右)/ vertical 竖排(上下' },
gap: { type: 'number', default: 24, description: '图间距(px)' },
charts: { type: 'array', required: true, description: '图表配置数组(N 张),每项同 /api/chart 参数;兼容 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: '背景色' }
},
@@ -1070,22 +1147,30 @@ 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 || {};
const direction = body.direction === 'vertical' ? 'vertical' : 'horizontal';
// 支持 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 = ['vertical', 'grid'].includes(body.direction) ? body.direction : 'horizontal';
const cols = Math.max(1, parseInt(body.cols) || 2);
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) => {
@@ -1104,38 +1189,68 @@ 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;
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);
let W, H;
const canvas = createCanvas(1, 1);
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);
if (direction === 'grid') {
// 多行多列网格:所有子图统一 contain 到最大单元格,按列填充
const rows = Math.ceil(subs.length / cols);
const cellW = Math.max(...subs.map(s => s.w));
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);
});
} 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 scaled;
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)) }));
W = tw;
H = scaled.reduce((sum, s) => sum + s.h, 0) + gap * (scaled.length - 1);
} 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);
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 buffer = canvas.toBuffer('image/png');
@@ -1143,13 +1258,14 @@ app.post('/api/combine', async (req, res) => {
'Content-Type': 'image/png',
'Content-Length': buffer.length,
'X-Combine-Direction': direction,
'X-Combine-Charts': String(subs.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 });
}
});
+38 -3
View File
@@ -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;
}
/* ===== 表格预览 ===== */