Compare commits

..
14 Commits
Author SHA1 Message Date
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
hz4th_coder 68e4b72d46 fix: 修复单图模式预览区遮挡导出预览 + 下载按钮宽度
1. 布局重构:右侧主区域改为上下排列(原图预览在上、导出预览在下),
   chartArea 高度 600→420px,导出预览不再被遮挡
2. 下载按钮:根因是 .btn-success 自带 flex:1 把按钮拉伸占满,已用
   flex:0 0 auto + width:auto 覆盖,189px→58px
2026-08-19 13:09:51 +08:00
hz4th_coder 6a696daeb8 feat: 单图模式新增双Y轴 + 下载按钮缩小
- 图表模式新增双Y轴:左右两个坐标轴,量度可不同
  - 显示选项加"双Y轴"开关,启用后显示右轴名称输入
  - 每个系列在系列配置中可选择左轴/右轴
  - 后端 /api/chart 支持 dualYAxis + rightAxisSeries + rightAxisName(POST/GET 均可)
- 导出设置折叠栏下载按钮改小,避免挤占
2026-08-19 12:59:46 +08:00
hz4th_coder d9b3253fa0 chore: 同步版本号至 v1.10.1 2026-08-19 12:50:40 +08:00
hz4th_coder 51d3e09378 feat: 导出设置支持展开/折叠(默认折叠)+ 恢复默认按钮
- 导出设置面板默认折叠,点击标题栏展开/收起,折叠栏显示当前分辨率摘要
- 折叠栏保留快速下载按钮,始终可用
- 新增🔄恢复默认按钮:一键重置分辨率/自定义/文件名/历史选择到初始状态(历史记录保留)
2026-08-19 12:50:26 +08:00
hz4th_coder 9a8cbb125c feat: 导出设置增强 —— 分辨率预设/自定义/历史记忆/日期命名
- 导出设置面板:分辨率预设(原始/1920×1080/1280×720/1024×768/800×600/自定义)
- 自定义宽高手动输入,页面实时居中预览导出效果(原图 contain 居中,自动适配深色背景)
- 分辨率历史记忆:localStorage 存最近5个,下拉一键复用
- 文件名:默认按日期时间自动命名(每次不同),支持手动命名(自动补扩展名)
- 三个模式(图表/表格/双图合并)统一走带分辨率的导出
- 修复:DOMContentLoaded 提前调用 generateCombine 覆盖 chartArea,导致图表模式 echarts canvas 被破坏
2026-08-19 12:40:22 +08:00
hz4th_coder 52a245e044 fix: 修复双图合并预览/导出为白图的问题
根因:renderOne 里 resolve(cvs) 后立即 chart.dispose(),Promise 微任务在
dispose 之后才执行,此时 echarts 已清空 canvas 内容,合并出来全是白图。
默认主题下因白底白图不易察觉(内容其实一直没画上)。

修复:resolve 前先把内容复制到独立 canvas 副本再 dispose,保证合并时
拿到的是有效像素。深色主题下验证通过(深色背景+彩色内容均正常)。
2026-08-19 12:27:04 +08:00
hz4th_coder 479d95cb2c chore: 同步健康检查版本号至 v1.9.0 2026-08-19 12:15:56 +08:00
hz4th_coder e62aac539b feat: 新增双图合并模式,支持横排/竖排将两张图合并为一张图
- 后端新增 POST /api/combine 接口(chart1/chart2 + direction/gap/pixelRatio/background)
- 前端新增 🖼️ 双图合并模式:两张图独立配置数据/标题/类型/主题
- 横排(左右等高三张图)或竖排(上下等宽),一键切换实时预览
- 修复 @napi-rs/canvas echarts 渲染需先 toBuffer 再 dispose 的关键问题
- 更新 README/API 文档
2026-08-19 12:15:31 +08:00
hz4th_coder 3107d56764 chore: 同步版本号至 v1.8.0(修正 tag 序列) 2026-08-19 11:57:08 +08:00
hz4th_coder 79a1ca36c5 feat: 添加 keepalive 保活脚本 + cron 定时检查
- 每5分钟检查服务是否运行
- 自动杀掉占用端口的非 node 进程(如 Python http.server)
- 自动重启 node 服务
2026-07-24 17:34:02 +08:00
7 changed files with 1324 additions and 78 deletions
+86 -2
View File
@@ -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 |
@@ -44,6 +45,11 @@ Content-Type: application/json
| `leftLabel` | string | — | `"左侧"` | 左侧区域标签 |
| `rightLabel` | string | — | `"右侧"` | 右侧区域标签 |
| `splitStyle` | string | — | `"solid"` | 分割线样式:`solid` / `dashed` / `dotted` |
| `dualYAxis` | boolean | — | `false` | 启用双Y轴(左右量度不同) |
| `rightAxisSeries` | array | — | `null` | 右轴系列名列表,如 `["利润"]`(未列出的系列用左轴) |
| `leftAxisName` | string | — | `""` | 左轴名称(如 `"销售额(元)"` |
| `rightAxisName` | string | — | `""` | 右轴名称(如 `"增长率(%)"` |
| `seriesTypes` | array | — | `null` | 每系列图表类型,按系列顺序对应,如 `["bar","line"]``bar`/`line`/`auto` |
| `width` | number | — | `800` | 图片宽度(px |
| `height` | number | — | `500` | 图片高度(px |
| `pixelRatio` | number | — | `2` | 像素倍率(越大越清晰) |
@@ -151,7 +157,85 @@ 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(多图合并)
将**多张图表**合并到一张图片中(默认 2 张,可多张),支持**横排(单行)**、**竖排(单列)**、**多行多列网格**三种排布,返回 PNG 图片。
### 请求
```
POST /api/combine
Content-Type: application/json
```
### 参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `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` |
> 兼容旧参数:`chart1` + `chart2` 仍可传(等价于 `charts: [chart1, chart2]`)。
每个子图配置支持:`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 '{
"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年销售",
"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
健康检查。
+14 -2
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.2.0-green.svg)
![Version](https://img.shields.io/badge/version-1.8.0-green.svg)
## ✨ 功能特性
@@ -11,6 +11,7 @@
- **柱状图** - 支持单系列/多系列对比
- **折线图** - 支持平滑曲线、面积填充
- **混合图** - 柱状图+折线图组合展示
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线)
### 📋 表格功能
- **表格图片生成** - 根据数据生成精美的表格图片
@@ -29,15 +30,26 @@
- 可自定义分割线样式(实线/虚线/点线)
- 自动标注左右区域标签
### 🖼️ 多图合并
- 将多张图表合并到一张图片中,适合对比/汇总场景,**默认2张,可一键不断添加**(支持删除)
- 支持**横排(单行)**、**竖排(单列)**、**多行多列网格**(可设每行列数),一键切换实时预览
- 每张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠
- 支持导出 PNG
### 📡 API 接口
- **POST /api/chart** - JSON 请求体生成图表(完整参数支持)
- **GET /api/chart** - URL 参数生成图表(简单场景)
- **POST /api/table** - JSON 请求体生成表格图片
- **GET /api/table** - URL 参数生成表格图片
- **POST /api/combine** - 多图合并(横排/竖排)生成一张图片
- 返回 PNG 图片,支持自定义分辨率和像素倍率
### 📥 导出功能
- Web UI 导出 PNG2倍分辨率
- 导出设置:分辨率预设(原始/1920×1080/1280×720/1024×768/800×600/自定义
- 自定义分辨率手动输入,页面实时居中预览保存后的图
- 分辨率历史记忆(最近5个,localStorage 持久化,一键复用)
- 文件名默认按日期时间自动命名(每次不同),支持手动命名
- Web UI 导出 PNG / 图表模式支持 SVG
- API 直接返回 PNG 图片流
## 🚀 快速开始
+638 -50
View File
@@ -3,6 +3,7 @@ let chartInstance = null;
let parsedData = null;
let seriesColors = [];
let seriesOrder = [];
let seriesAxis = []; // 每个系列所属轴:0=左轴 1=右轴
let currentMode = 'chart'; // 'chart' or 'table'
let tableImageBlob = null;
@@ -59,6 +60,13 @@ document.addEventListener('DOMContentLoaded', () => {
// 默认加载第一个示例
document.getElementById('dataInput').value = sampleDataSets[0].data;
generateChart();
// 初始化多图合并(预填示例,不提前生成,切到该模式时由 switchMode 触发)
initCombineCharts();
// 初始化导出设置
renderHistory();
refreshExportHint();
});
// ===== 数据解析 =====
@@ -120,6 +128,7 @@ function generateChart() {
seriesOrder = parsedData.seriesNames.map((_, i) => i);
const palette = colorPalettes[document.getElementById('themeStyle').value] || colorPalettes.default;
seriesColors = parsedData.seriesNames.map((_, i) => palette[i % palette.length]);
seriesAxis = parsedData.seriesNames.map(() => 0);
// 渲染系列配置
renderSeriesConfig();
@@ -127,6 +136,8 @@ function generateChart() {
// 初始化图表
initChart();
updateChart();
refreshPreview();
}
// ===== 初始化图表实例 =====
@@ -159,6 +170,9 @@ function updateChart() {
const stackMode = document.getElementById('stackMode').checked;
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;
// 显示/隐藏分割配置
document.getElementById('splitConfig').style.display = enableSplit ? 'block' : 'none';
@@ -174,10 +188,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,
@@ -235,6 +256,11 @@ function updateChart() {
};
}
// 双Y轴:按系列指定左右轴
if (dualAxis) {
seriesItem.yAxisIndex = (seriesAxis[origIdx] === 1) ? 1 : 0;
}
return seriesItem;
});
@@ -366,7 +392,30 @@ function updateChart() {
},
axisTick: { show: false }
},
yAxis: {
yAxis: dualAxis ? [
{
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 },
@@ -388,6 +437,8 @@ function updateChart() {
// 绘制分割线(在 category 间隙中)
drawSplitLine();
refreshPreview();
}
// ===== 在柱子间隙中精确绘制分割线 =====
@@ -451,6 +502,13 @@ function renderSeriesConfig() {
seriesOrder.forEach((origIdx, displayIdx) => {
const name = parsedData.seriesNames[origIdx];
const color = seriesColors[origIdx];
const dualAxis = document.getElementById('dualAxis').checked;
const axisSelect = dualAxis ? `
<select onchange="updateSeriesAxis(${origIdx}, this.value)" title="所属坐标轴">
<option value="0" ${seriesAxis[origIdx] === 0 ? 'selected' : ''}>左轴</option>
<option value="1" ${seriesAxis[origIdx] === 1 ? 'selected' : ''}>右轴</option>
</select>
` : '';
const item = document.createElement('div');
item.className = 'series-item';
@@ -466,6 +524,7 @@ function renderSeriesConfig() {
<option value="bar">柱状</option>
<option value="line">折线</option>
</select>
${axisSelect}
`;
// 拖拽事件
@@ -551,6 +610,19 @@ function updateSeriesType(origIdx, type) {
updateChart();
}
// ===== 双Y轴 =====
function onDualAxisChange() {
const on = document.getElementById('dualAxis').checked;
document.getElementById('rightAxisGroup').style.display = on ? 'block' : 'none';
renderSeriesConfig(); // 显示/隐藏系列轴选择
updateChart();
}
function updateSeriesAxis(origIdx, axis) {
seriesAxis[origIdx] = parseInt(axis) || 0;
updateChart();
}
// ===== 工具函数 =====
function adjustColor(hex, amount) {
hex = hex.replace('#', '');
@@ -585,37 +657,229 @@ function clearData() {
document.getElementById('seriesConfig').innerHTML = '<p class="hint-text">生成图表后可在此调整各系列的顺序和颜色</p>';
}
// ===== 导出图表 =====
function exportChart(format) {
// ===== 导出图表SVG 专用) =====
function exportChartSvg() {
if (!chartInstance) {
alert('请先生成图表');
return;
}
const svgChart = echarts.init(document.createElement('div'), null, { renderer: 'svg' });
svgChart.setOption(chartInstance.getOption());
const url = svgChart.getDataURL({ type: 'svg', pixelRatio: 2 });
const a = document.createElement('a');
a.href = url;
a.download = buildExportFilename('chart', 'svg');
a.click();
svgChart.dispose();
}
if (format === 'png') {
const url = chartInstance.getDataURL({
type: 'png',
pixelRatio: 2,
backgroundColor: document.getElementById('themeStyle').value === 'dark' ? '#1a1a2e' : '#fff'
});
const a = document.createElement('a');
a.href = url;
a.download = 'chart.png';
a.click();
} else if (format === 'svg') {
// 重新用 SVG 渲染
const svgChart = echarts.init(document.createElement('div'), null, { renderer: 'svg' });
svgChart.setOption(chartInstance.getOption());
const url = svgChart.getDataURL({
type: 'svg',
pixelRatio: 2
});
const a = document.createElement('a');
a.href = url;
a.download = 'chart.svg';
a.click();
svgChart.dispose();
// ===== 导出设置(分辨率/历史/命名) =====
const EXPORT_PRESETS = {
'original': { label: '原始大小', w: 0, h: 0 },
'1920x1080': { label: '1920 × 1080', w: 1920, h: 1080 },
'1280x720': { label: '1280 × 720', w: 1280, h: 720 },
'1024x768': { label: '1024 × 768', w: 1024, h: 768 },
'800x600': { label: '800 × 600', w: 800, h: 600 },
'custom': { label: '自定义', w: null, h: null }
};
const HISTORY_KEY = 'dct_export_history';
const HISTORY_MAX = 5;
// 当前生效的分辨率 {w, h}(0 表示原始大小)
function getResolution() {
const preset = document.getElementById('exportPreset').value;
if (preset === 'original') return { w: 0, h: 0 };
if (preset === 'custom') {
const w = parseInt(document.getElementById('customWidth').value) || 0;
const h = parseInt(document.getElementById('customHeight').value) || 0;
return { w, h };
}
const p = EXPORT_PRESETS[preset];
return { w: p.w, h: p.h };
}
function onExportPresetChange() {
const preset = document.getElementById('exportPreset').value;
document.getElementById('customResGroup').style.display = preset === 'custom' ? 'block' : 'none';
refreshExportHint();
refreshPreview();
}
// 导出设置面板展开/折叠(默认折叠)
let exportSettingsOpen = false;
function toggleExportSettings() {
exportSettingsOpen = !exportSettingsOpen;
const body = document.getElementById('exportSettingsBody');
const t = document.getElementById('exportToggle');
body.style.display = exportSettingsOpen ? 'block' : 'none';
t.textContent = exportSettingsOpen ? '▾' : '▸';
}
// 一键恢复默认(重置全部设置,历史记录保留)
function resetExportSettings() {
document.getElementById('exportPreset').value = 'original';
document.getElementById('customResGroup').style.display = 'none';
document.getElementById('customWidth').value = '';
document.getElementById('customHeight').value = '';
document.getElementById('exportFilename').value = '';
document.getElementById('exportHistory').value = '';
refreshExportHint();
refreshPreview();
}
function onCustomResInput() {
refreshExportHint();
refreshPreview();
const { w, h } = getResolution();
if (w > 0 && h > 0) saveHistory(w, h);
}
// 历史分辨率管理(最多 5 个,存 localStorage
function loadHistory() {
try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; } catch (e) { return []; }
}
function saveHistory(w, h) {
if (!w || !h) return;
let hist = loadHistory();
const key = w + 'x' + h;
hist = hist.filter(x => x.key !== key);
hist.unshift({ key, w, h });
hist = hist.slice(0, HISTORY_MAX);
localStorage.setItem(HISTORY_KEY, JSON.stringify(hist));
renderHistory();
}
function renderHistory() {
const sel = document.getElementById('exportHistory');
const hist = loadHistory();
const cur = sel.value;
sel.innerHTML = hist.length
? hist.map(h => `<option value="${h.key}">${h.w} × ${h.h}</option>`).join('')
: '<option value="">— 暂无历史 —</option>';
if (cur && hist.some(h => h.key === cur)) sel.value = cur;
}
function onHistorySelect() {
const v = document.getElementById('exportHistory').value;
if (!v) return;
const [w, h] = v.split('x').map(Number);
const preset = Object.entries(EXPORT_PRESETS).find(([k, p]) => p.w === w && p.h === h);
document.getElementById('exportPreset').value = preset ? preset[0] : 'custom';
if (preset) {
document.getElementById('customResGroup').style.display = 'none';
} else {
document.getElementById('customResGroup').style.display = 'block';
document.getElementById('customWidth').value = w;
document.getElementById('customHeight').value = h;
}
refreshExportHint();
refreshPreview();
}
// 文件名:手动填写优先,否则按日期时间自动命名(每次不同)
function buildExportFilename(prefix, ext) {
ext = ext || 'png';
const manual = document.getElementById('exportFilename').value.trim();
if (manual) {
return /\.[a-zA-Z0-9]+$/.test(manual) ? manual : manual + '.' + ext;
}
const d = new Date();
const pad = n => String(n).padStart(2, '0');
return `${prefix}_${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}.${ext}`;
}
// 把源图 contain 居中缩放到目标分辨率画布(自动适配深色背景)
function applyResolution(srcCanvas, tw, th) {
if (!tw || !th) return srcCanvas;
let bg = '#ffffff';
try {
const d = srcCanvas.getContext('2d').getImageData(0, 0, 1, 1).data;
if (d[3] > 0 && (d[0] + d[1] + d[2]) / 3 < 100) bg = '#1a1a2e';
} catch (e) {}
const c = document.createElement('canvas');
c.width = tw; c.height = th;
const ctx = c.getContext('2d');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, tw, th);
const scale = Math.min(tw / srcCanvas.width, th / srcCanvas.height);
const dw = Math.round(srcCanvas.width * scale);
const dh = Math.round(srcCanvas.height * scale);
ctx.drawImage(srcCanvas, Math.round((tw - dw) / 2), Math.round((th - dh) / 2), dw, dh);
return c;
}
// 获取当前模式源 canvas(异步)
function getSourceCanvas() {
return new Promise((resolve, reject) => {
if (currentMode === 'chart') {
if (!chartInstance) return reject(new Error('请先生成图表'));
const cvs = chartInstance.getDom().querySelector('canvas');
if (!cvs) return reject(new Error('图表未渲染'));
resolve(cvs);
} else if (currentMode === 'table') {
if (!tableImageBlob) return reject(new Error('请先生成表格'));
const url = URL.createObjectURL(tableImageBlob);
const img = new Image();
img.onload = () => {
const c = document.createElement('canvas');
c.width = img.width; c.height = img.height;
c.getContext('2d').drawImage(img, 0, 0);
URL.revokeObjectURL(url);
resolve(c);
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('表格图片加载失败')); };
img.src = url;
} else {
if (!combinedCanvas) return reject(new Error('请先生成合并图'));
resolve(combinedCanvas);
}
});
}
// 生成最终导出 canvas(应用当前分辨率)
function getExportCanvas() {
return getSourceCanvas().then(src => {
const { w, h } = getResolution();
return applyResolution(src, w, h);
});
}
// 统一下载图片
function exportCurrent(prefix) {
getExportCanvas().then(canvas => {
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = buildExportFilename(prefix);
a.click();
const { w, h } = getResolution();
if (w > 0 && h > 0) saveHistory(w, h);
}).catch(err => alert(err.message));
}
// 导出分辨率提示 + 折叠栏摘要
function refreshExportHint() {
const { w, h } = getResolution();
const hint = document.getElementById('exportResHint');
if (hint) hint.textContent = w > 0 && h > 0 ? `导出分辨率:${w} × ${h}(原图居中缩放)` : '导出分辨率:原始大小';
const sum = document.getElementById('exportSummary');
if (sum) sum.textContent = w > 0 && h > 0 ? `${w} × ${h}` : '原始大小';
}
// 刷新导出预览:以原图为中心,居中展示保存后的图
function refreshPreview() {
const box = document.getElementById('exportPreviewBox');
const inner = document.getElementById('exportPreviewInner');
const title = document.getElementById('exportPreviewTitle');
if (!box || !inner) return;
getExportCanvas().then(canvas => {
const { w, h } = getResolution();
const url = canvas.toDataURL('image/png');
const sizeTag = w > 0 && h > 0 ? ` <span class="export-preview-size">${w} × ${h}</span>` : '';
title.innerHTML = `📐 导出预览${sizeTag}`;
inner.innerHTML = `<img src="${url}" alt="导出预览">`;
box.style.display = 'block';
}).catch(() => { box.style.display = 'none'; });
}
// ===== 模式切换 =====
@@ -625,16 +889,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 +920,10 @@ function switchMode(mode) {
function generate() {
if (currentMode === 'chart') {
generateChart();
} else {
} else if (currentMode === 'table') {
generateTable();
} else {
generateCombine();
}
}
@@ -693,6 +967,7 @@ function generateTable() {
tableImageBlob = blob;
const url = URL.createObjectURL(blob);
chartArea.innerHTML = `<div class="table-preview"><img src="${url}" alt="表格预览"></div>`;
refreshPreview();
})
.catch(err => {
chartArea.innerHTML = `<div class="placeholder"><p>❌</p><p>${err.message}</p></div>`;
@@ -708,26 +983,21 @@ function updateTable() {
// ===== 统一导出图片 =====
function exportImage(format) {
if (currentMode === 'chart') {
exportChart(format);
} else {
exportTable(format);
}
}
// ===== 导出表格 =====
function exportTable(format) {
if (!tableImageBlob) {
alert('请先生成表格');
if (format === 'svg') {
if (currentMode !== 'chart') {
alert('SVG 仅图表模式支持');
return;
}
exportChartSvg();
return;
}
const prefix = currentMode === 'chart' ? 'chart' : currentMode === 'table' ? 'table' : 'combine';
exportCurrent(prefix);
}
const url = URL.createObjectURL(tableImageBlob);
const a = document.createElement('a');
a.href = url;
a.download = 'table.png';
a.click();
URL.revokeObjectURL(url);
// ===== 导出表格(走统一导出) =====
function exportTable(format) {
exportCurrent('table');
}
// ===== 快速切换风格 =====
@@ -741,11 +1011,12 @@ function quickSwitchTheme(theme) {
// 图表模式:更新主题选择器并重新渲染
document.getElementById('themeStyle').value = theme;
updateChart();
} else {
} else if (currentMode === 'table') {
// 表格模式:更新主题选择器并重新请求
document.getElementById('tableTheme').value = theme;
updateTable();
}
// 多图合并模式:仅切换按钮高亮,不联动(各图独立主题)
}
// ===== 同步风格按钮状态(下拉框变更时) =====
@@ -767,3 +1038,320 @@ 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 };
}
// ===== 多图合并管理 =====
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', '柱状图+折线图混合']];
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 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 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
});
if (!option) { reject(new Error('数据格式错误')); return; }
const holder = document.createElement('div');
holder.style.cssText = `position:absolute;left:-9999px;top:0;width:${w}px;height:${h}px;`;
document.body.appendChild(holder);
const chart = echarts.init(holder, null, { renderer: 'canvas' });
chart.setOption(option);
setTimeout(() => {
const srcCvs = holder.querySelector('canvas');
if (!srcCvs) { reject(new Error('子图渲染失败')); return; }
// 关键:先把内容复制到独立 canvas 再 dispose,避免 dispose 清空内容
const copy = document.createElement('canvas');
copy.width = srcCvs.width;
copy.height = srcCvs.height;
copy.getContext('2d').drawImage(srcCvs, 0, 0);
chart.dispose();
holder.remove();
resolve(copy);
}, 80);
});
Promise.all(validCharts.map(c => {
const m = measureCombineChart(c.data);
return renderOne(c, m.w, m.h);
})).then(canvases => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
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 {
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;
const url = canvas.toDataURL('image/png');
chartArea.innerHTML = `<div class="table-preview"><img src="${url}" alt="合并图预览" style="max-width:100%;"></div>`;
refreshPreview();
}).catch(err => {
chartArea.innerHTML = `<div class="placeholder"><p>❌</p><p>${err.message}</p></div>`;
});
}
// 导出合并图(走统一导出)
function exportCombine() {
exportCurrent('combine');
}
+104 -11
View File
@@ -23,11 +23,12 @@
<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>
</div>
</div>
<!-- 数据输入区 -->
<div class="panel-section">
<div class="panel-section" id="dataInputSection">
<h2>📝 数据输入</h2>
<div class="input-format-hint">
<p><strong>格式说明:</strong></p>
@@ -99,8 +100,19 @@ C, 20, 30, 40</pre>
<label><input type="checkbox" id="showLabel" onchange="updateChart()"> 数据标签</label>
<label><input type="checkbox" id="stackMode" onchange="updateChart()"> 堆叠模式</label>
<label><input type="checkbox" id="smoothLine" checked onchange="updateChart()"> 平滑曲线</label>
<label><input type="checkbox" id="dualAxis" onchange="onDualAxisChange()"> 双Y轴</label>
</div>
</div>
<div class="config-group" id="rightAxisGroup" style="display:none;">
<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>
<!-- 表格配置区 -->
@@ -207,24 +219,105 @@ C, 20, 30, 40</pre>
</div>
</div>
<!-- 导出按钮 -->
<div class="panel-section">
<!-- 导出设置 + 按钮(默认折叠) -->
<div class="panel-section" id="exportSection">
<div class="export-header">
<div class="export-header-main" onclick="toggleExportSettings()" title="点击展开/折叠">
<h2>📤 导出设置</h2>
<span class="export-summary" id="exportSummary">原始大小</span>
<span class="export-toggle" id="exportToggle"></span>
</div>
<button class="btn btn-success btn-sm" onclick="exportImage('png')">📥 下载</button>
</div>
<div id="exportSettingsBody" class="export-settings-body" style="display:none;">
<div class="config-group">
<label>分辨率</label>
<select id="exportPreset" onchange="onExportPresetChange()">
<option value="original">原始大小</option>
<option value="1920x1080">1920 × 1080</option>
<option value="1280x720">1280 × 720</option>
<option value="1024x768">1024 × 768</option>
<option value="800x600">800 × 600</option>
<option value="custom">自定义</option>
</select>
</div>
<div class="config-group" id="customResGroup" style="display:none;">
<label>自定义分辨率(宽 × 高)</label>
<div class="res-input-row">
<input type="number" id="customWidth" min="1" max="8192" placeholder="宽" oninput="onCustomResInput()">
<span class="res-times">×</span>
<input type="number" id="customHeight" min="1" max="8192" placeholder="高" oninput="onCustomResInput()">
</div>
</div>
<div class="config-group">
<label>历史分辨率(最近5个)</label>
<select id="exportHistory" onchange="onHistorySelect()">
<option value="">— 暂无历史 —</option>
</select>
</div>
<div class="config-group">
<label>文件名</label>
<input type="text" id="exportFilename" placeholder="留空则按日期时间自动命名">
<p class="hint-text" id="exportResHint"></p>
</div>
<div class="btn-group">
<button class="btn btn-secondary" onclick="resetExportSettings()">🔄 恢复默认</button>
<button class="btn btn-success" id="btnExportSvg" onclick="exportImage('svg')">📥 导出SVG</button>
</div>
</div>
</div>
<!-- 多图合并配置区 -->
<div class="panel-section" id="combineConfigSection" style="display:none;">
<h2>🖼️ 多图合并配置</h2>
<div class="config-group">
<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="grid" onchange="onCombineGridToggle()"> ▦ 多行多列</label>
</div>
</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 id="combineChartsContainer">
<!-- 图表卡片由 JS 动态渲染 -->
</div>
<div class="btn-group">
<button class="btn btn-success" onclick="exportImage('png')">📥 导出PNG</button>
<button class="btn btn-success" id="btnExportSvg" onclick="exportImage('svg')">📥 导出SVG</button>
<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>
</div>
</div>
<!-- 右侧面板:图表预览 + 风格切换 -->
<!-- 右侧面板:图表预览 + 导出预览 + 风格切换 -->
<div class="right-panel">
<div class="chart-container">
<div id="chartArea" class="chart-area">
<div class="placeholder">
<p>📊</p>
<p>输入数据后点击"生成图表"</p>
<div class="right-main">
<div class="chart-container">
<div id="chartArea" class="chart-area">
<div class="placeholder">
<p>📊</p>
<p>输入数据后点击"生成图表"</p>
</div>
</div>
</div>
<!-- 导出效果预览(按分辨率居中展示保存后的图) -->
<div class="export-preview-box" id="exportPreviewBox" style="display:none;">
<div class="export-preview-title" id="exportPreviewTitle">📐 导出预览</div>
<div class="export-preview-inner" id="exportPreviewInner"></div>
</div>
</div>
<!-- 风格快速切换按钮(放在图表右侧) -->
<div class="theme-buttons-section">
Executable
+55
View File
@@ -0,0 +1,55 @@
#!/bash
# data-chart-tool 保活脚本
# 检查服务是否运行,如果没有则启动
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 进程
PID=$(ss -tlnp | grep ":$PORT " | grep -oP 'pid=\K[0-9]+' | head -1)
if [ -n "$PID" ]; then
CMD=$(ps -p "$PID" -o comm= 2>/dev/null)
if [ "$CMD" = "node" ] || [ "$CMD" = "MainThread" ]; then
# node 服务在运行,检查是否正常响应
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:$PORT/api/health 2>/dev/null)
if [ "$HTTP_CODE" = "200" ]; then
echo "[$(date)] Service OK (PID=$PID, HTTP=$HTTP_CODE)"
exit 0
fi
fi
# 端口被非 node 进程占用(如 Python http.server),杀掉它
echo "[$(date)] Port $PORT occupied by $CMD (PID=$PID), killing..."
kill -9 "$PID" 2>/dev/null
sleep 1
fi
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_BIN" server.js >> "$LOG_FILE" 2>&1 &
echo "[$(date)] Started with PID $!"
# 等待启动
sleep 2
# 验证
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:$PORT/api/health 2>/dev/null)
if [ "$HTTP_CODE" = "200" ]; then
echo "[$(date)] Service started successfully (HTTP=$HTTP_CODE)"
else
echo "[$(date)] WARNING: Service may not have started correctly (HTTP=$HTTP_CODE)"
fi
+207 -8
View File
@@ -1,6 +1,6 @@
const express = require('express');
const cors = require('cors');
const { createCanvas, registerFont } = require('@napi-rs/canvas');
const { createCanvas, registerFont, loadImage } = require('@napi-rs/canvas');
const echarts = require('echarts');
const path = require('path');
@@ -113,7 +113,12 @@ function buildChartOption(params) {
leftLabel = '左侧',
rightLabel = '右侧',
splitStyle = 'solid',
seriesColors: customColors = null
seriesColors: customColors = null,
dualYAxis = false,
rightAxisSeries = null,
leftAxisName = '',
rightAxisName = '',
seriesTypes = null
} = params;
const parsedData = parseData(data);
@@ -134,6 +139,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,
@@ -148,6 +158,12 @@ function buildChartOption(params) {
}
};
// 双Y轴:rightAxisSeries 指定右轴系列名
if (dualYAxis) {
const rightNames = Array.isArray(rightAxisSeries) ? rightAxisSeries : (rightAxisSeries ? [rightAxisSeries] : []);
seriesItem.yAxisIndex = rightNames.includes(name) ? 1 : 0;
}
if (stackMode) {
seriesItem.stack = 'total';
}
@@ -159,8 +175,7 @@ function buildChartOption(params) {
seriesItem.areaStyle = theme === 'gradient' ? { opacity: 0.15 } : undefined;
}
if (type === 'bar') {
seriesItem.barMaxWidth = 40;
if (type === 'bar') {seriesItem.barMaxWidth = 40;
seriesItem.itemStyle.borderRadius = stackMode ? [0, 0, 0, 0] : [4, 4, 0, 0];
}
@@ -269,7 +284,33 @@ function buildChartOption(params) {
},
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 },
@@ -366,6 +407,11 @@ app.get('/api/chart', (req, res) => {
leftLabel: req.query.leftLabel || '左侧',
rightLabel: req.query.rightLabel || '右侧',
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',
@@ -882,7 +928,7 @@ app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
service: 'data-chart-tool',
version: '1.2.0',
version: '1.13.1',
endpoints: {
'POST /api/chart': '生成图表图片(JSON body',
'GET /api/chart': '生成图表图片(URL 参数)',
@@ -897,7 +943,7 @@ app.get('/api/health', (req, res) => {
app.get('/api/docs', (req, res) => {
res.json({
name: '数据可视化图表生成器 API',
version: '1.2.0',
version: '1.13.1',
endpoints: [
{
method: 'POST',
@@ -922,7 +968,12 @@ app.get('/api/docs', (req, res) => {
width: { type: 'number', default: 800, description: '图片宽度(px)' },
height: { type: 'number', default: 500, description: '图片高度(px)' },
format: { type: 'string', default: 'png', options: ['png'], description: '输出格式' },
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' }
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' },
dualYAxis: { type: 'boolean', default: false, description: '是否启用双Y轴(左右量度不同)' },
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' }
},
returns: 'image/png',
example: {
@@ -1000,17 +1051,165 @@ app.get('/api/docs', (req, res) => {
example: {
request: `curl "http://localhost:16016/api/table?data=产品,价格,库存\\n手机,2999,100\\n平板,1999,50&title=产品列表" -o table.png`
}
},
{
method: 'POST',
path: '/api/combine',
description: '将多张图表合并到一张图片中(支持横排/竖排)',
'Content-Type': 'application/json',
params: {
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: '背景色' }
},
returns: 'image/png',
example: {
request: `curl -X POST http://localhost:16016/api/combine \\
-H "Content-Type: application/json" \\
-d '{
"chart1": {"data": "产品, Q1, Q2\\n手机, 1200, 1800\\n平板, 800, 950", "title": "2024年销售", "chartType": "bar"},
"chart2": {"data": "月份, 营收\\n1月, 500\\n2月, 680\\n3月, 820", "title": "营收趋势", "chartType": "line"},
"direction": "horizontal"
}' -o combine.png`,
response: 'PNG 图片二进制流'
}
}
]
});
});
// ===== API: 多图合并生成图片 =====
app.post('/api/combine', async (req, res) => {
try {
const body = req.body || {};
// 支持 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';
// 渲染单个子图(直接按目标尺寸渲染,避免二次缩放损失)
// 注意:必须先 toBuffer 再 chart.dispose()dispose 会清空 canvas 内容!
const renderSub = async (params, w, h) => {
const option = buildChartOption(params);
const c = createCanvas(w * pixelRatio, h * pixelRatio);
const chart = echarts.init(c, null, {
renderer: 'canvas',
width: w,
height: h,
devicePixelRatio: pixelRatio
});
chart.setOption(option);
const buf = c.toBuffer('image/png');
chart.dispose();
const img = await loadImage(buf);
return { img, w, h };
};
const subs = await Promise.all(charts.map(c =>
renderSub(c, parseInt(c.width) || 640, parseInt(c.height) || 420)
));
let W, H;
const canvas = createCanvas(1, 1);
const ctx = canvas.getContext('2d');
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 {
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');
res.set({
'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 });
}
});
// ===== 启动服务 =====
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 数据可视化图表生成器已启动`);
console.log(`📊 Web UI: http://0.0.0.0:${PORT}`);
console.log(`📡 图表API: http://0.0.0.0:${PORT}/api/chart`);
console.log(`📋 表格API: http://0.0.0.0:${PORT}/api/table`);
console.log(`🖼️ 合并API: http://0.0.0.0:${PORT}/api/combine`);
console.log(`📖 文档: http://0.0.0.0:${PORT}/api/docs`);
console.log(`❤️ 健康: http://0.0.0.0:${PORT}/api/health`);
});
+220 -5
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);
@@ -325,9 +333,18 @@ body {
align-items: flex-start;
}
.chart-container {
/* 右侧主区域:原图预览 + 导出预览 上下排列 */
.right-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.chart-container {
flex: none;
min-width: 0;
background: var(--panel-bg);
border-radius: var(--radius);
padding: 20px;
@@ -337,7 +354,7 @@ body {
.chart-area {
width: 100%;
height: 600px;
height: 420px;
}
.placeholder {
@@ -370,7 +387,7 @@ body {
}
.chart-area {
height: 450px;
height: 320px;
}
/* 小屏幕下风格按钮回到图表下方 */
@@ -428,6 +445,80 @@ body {
color: white;
}
/* ===== 双图合并 ===== */
.combine-direction {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.dir-option {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: #f8fafc;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.dir-option:hover {
border-color: var(--primary);
}
.dir-option:has(input:checked) {
border-color: var(--primary);
background: #eef2ff;
color: var(--primary);
font-weight: 500;
}
.combine-chart-card {
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
margin-bottom: 14px;
background: #f8fafc;
}
.combine-chart-card h3 {
font-size: 0.95rem;
margin: 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);
}
.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;
}
/* ===== 表格预览 ===== */
.table-preview {
width: 100%;
@@ -560,3 +651,127 @@ input[type="range"]::-moz-range-thumb {
background: #cbd5e1;
border-radius: 2px;
}
/* ===== 导出设置 ===== */
.res-input-row {
display: flex;
align-items: center;
gap: 8px;
}
.res-input-row input[type="number"] {
flex: 1;
min-width: 0;
}
.res-times {
color: var(--text-light);
font-weight: 600;
}
/* ===== 导出预览(按分辨率居中展示保存后的图) ===== */
.export-preview-box {
margin-top: 14px;
border: 1px dashed var(--border);
border-radius: 10px;
background: #ffffff;
padding: 10px;
}
.export-preview-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-light);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.export-preview-inner {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
background: repeating-conic-gradient(#f1f5f9 0% 25%, #ffffff 0% 50%) 0 0 / 20px 20px;
border-radius: 8px;
padding: 12px;
overflow: auto;
}
.export-preview-inner img {
max-width: 100%;
max-height: 300px;
box-shadow: 0 4px 14px rgba(0,0,0,0.12);
border-radius: 4px;
background: #fff;
}
.export-preview-size {
font-size: 0.78rem;
color: var(--primary);
background: #eef2ff;
border-radius: 4px;
padding: 1px 8px;
font-weight: 600;
}
/* ===== 导出设置折叠栏 ===== */
.export-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.export-header-main {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
cursor: pointer;
user-select: none;
padding: 2px 0;
}
.export-header h2 {
font-size: 1rem;
margin: 0;
color: var(--text);
}
.export-summary {
font-size: 0.75rem;
color: var(--primary);
background: #eef2ff;
padding: 2px 8px;
border-radius: 10px;
font-weight: 600;
white-space: nowrap;
}
.export-toggle {
color: var(--text-light);
font-size: 0.8rem;
transition: transform 0.2s;
}
.export-settings-body {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--border);
}
.btn-sm {
padding: 4px 10px;
font-size: 0.78rem;
}
/* 导出设置折叠栏下载按钮(更小,避免挤占) */
.export-header .btn-sm {
flex: 0 0 auto;
width: auto;
padding: 3px 8px;
font-size: 0.74rem;
white-space: nowrap;
}