Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a03ecc063 | ||
|
|
d7c7c08407 | ||
|
|
6b5223daec |
@@ -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` | 显示图例 |
|
||||
@@ -159,7 +159,7 @@ 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(多图合并)
|
||||
|
||||
将**多张图表**合并到一张图片中(默认 2 张,可多张),支持**横排(左右并排)** 或 **竖排(上下堆叠)** 两种方向,返回 PNG 图片。
|
||||
将**多张图表**合并到一张图片中(默认 2 张,可多张),支持**横排(单行)**、**竖排(单列)**、**多行多列网格**三种排布,返回 PNG 图片。
|
||||
|
||||
### 请求
|
||||
|
||||
@@ -173,14 +173,15 @@ Content-Type: application/json
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `charts` | array | ✅ | 图表配置数组(N 张),每项同 `/api/chart` 参数:data/chartType/title/theme 等 |
|
||||
| `direction` | string | 否 | `horizontal` 横排(默认)/ `vertical` 竖排 |
|
||||
| `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`。
|
||||
每个子图配置支持:`data`(CSV)、`chartType`(bar/line/bar-line/pie/radar)、`title`、`theme`、`showLegend`、`showGrid`、`showLabel`、`stackMode`、`smoothLine`、`width`、`height`。
|
||||
|
||||
### curl 示例
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
- **柱状图** - 支持单系列/多系列对比
|
||||
- **折线图** - 支持平滑曲线、面积填充
|
||||
- **混合图** - 柱状图+折线图组合展示
|
||||
- **饼图** - 环形占比展示(第一列=名称,第一系列=数值)
|
||||
- **雷达图** - 多维度对比(第一列=维度,每个系列=一个多边形)
|
||||
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线)
|
||||
|
||||
### 📋 表格功能
|
||||
@@ -32,7 +34,7 @@
|
||||
|
||||
### 🖼️ 多图合并
|
||||
- 将多张图表合并到一张图片中,适合对比/汇总场景,**默认2张,可一键不断添加**(支持删除)
|
||||
- 支持**横排(左右并排)** 和 **竖排(上下堆叠)**,一键切换实时预览
|
||||
- 支持**横排(单行)**、**竖排(单列)**、**多行多列网格**(可设每行列数),一键切换实时预览
|
||||
- 每张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠
|
||||
- 支持导出 PNG
|
||||
|
||||
|
||||
@@ -157,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;
|
||||
@@ -174,6 +259,16 @@ function updateChart() {
|
||||
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';
|
||||
|
||||
@@ -1016,7 +1111,7 @@ function quickSwitchTheme(theme) {
|
||||
document.getElementById('tableTheme').value = theme;
|
||||
updateTable();
|
||||
}
|
||||
// 双图合并模式:仅切换按钮高亮,不联动(两图各自独立主题)
|
||||
// 多图合并模式:仅切换按钮高亮,不联动(各图独立主题)
|
||||
}
|
||||
|
||||
// ===== 同步风格按钮状态(下拉框变更时) =====
|
||||
@@ -1039,7 +1134,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
initThemeButtons();
|
||||
});
|
||||
|
||||
// ===== 双图合并 =====
|
||||
// ===== 多图合并 =====
|
||||
let combinedCanvas = null;
|
||||
|
||||
// 构建单个子图的 echarts option(不依赖全局状态,双图模式专用)
|
||||
@@ -1058,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;
|
||||
@@ -1177,7 +1332,7 @@ function initCombineCharts() {
|
||||
renderCombineCharts();
|
||||
}
|
||||
|
||||
const COMBINE_TYPES = [['bar', '柱状图'], ['line', '折线图'], ['bar-line', '柱状图+折线图混合']];
|
||||
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) {
|
||||
@@ -1245,6 +1400,13 @@ function removeCombineChart(i) {
|
||||
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());
|
||||
@@ -1284,34 +1446,55 @@ function generateCombine() {
|
||||
const m = measureCombineChart(c.data);
|
||||
return renderOne(c, m.w, m.h);
|
||||
})).then(canvases => {
|
||||
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;
|
||||
}
|
||||
|
||||
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') {
|
||||
let y = 0;
|
||||
scaled.forEach(x => { ctx.drawImage(x.c, 0, y, x.w, x.h); y += x.h + gap; });
|
||||
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 x = 0;
|
||||
scaled.forEach(s => { ctx.drawImage(s.c, x, 0, s.w, s.h); x += s.w + gap; });
|
||||
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;
|
||||
|
||||
+13
-5
@@ -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>
|
||||
|
||||
@@ -272,18 +274,24 @@ C, 20, 30, 40</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 双图合并配置区 -->
|
||||
<!-- 多图合并配置区 -->
|
||||
<div class="panel-section" id="combineConfigSection" style="display:none;">
|
||||
<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="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>
|
||||
|
||||
+11
-1
@@ -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 $!"
|
||||
|
||||
# 等待启动
|
||||
|
||||
@@ -130,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];
|
||||
@@ -928,7 +994,7 @@ app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'data-chart-tool',
|
||||
version: '1.13.0',
|
||||
version: '1.14.0',
|
||||
endpoints: {
|
||||
'POST /api/chart': '生成图表图片(JSON body)',
|
||||
'GET /api/chart': '生成图表图片(URL 参数)',
|
||||
@@ -943,7 +1009,7 @@ app.get('/api/health', (req, res) => {
|
||||
app.get('/api/docs', (req, res) => {
|
||||
res.json({
|
||||
name: '数据可视化图表生成器 API',
|
||||
version: '1.13.0',
|
||||
version: '1.14.0',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -952,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: '是否显示图例' },
|
||||
@@ -1059,7 +1125,8 @@ app.get('/api/docs', (req, res) => {
|
||||
'Content-Type': 'application/json',
|
||||
params: {
|
||||
charts: { type: 'array', required: true, description: '图表配置数组(N 张),每项同 /api/chart 参数;兼容 chart1 + chart2' },
|
||||
direction: { type: 'string', default: 'horizontal', options: ['horizontal', 'vertical'], description: '排列方向:horizontal 横排(左右)/ vertical 竖排(上下)' },
|
||||
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: '背景色' }
|
||||
@@ -1098,7 +1165,8 @@ app.post('/api/combine', async (req, res) => {
|
||||
return res.status(400).json({ error: '至少需要一个有数据的图表(data 参数)' });
|
||||
}
|
||||
|
||||
const direction = body.direction === 'vertical' ? 'vertical' : 'horizontal';
|
||||
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';
|
||||
@@ -1125,39 +1193,64 @@ app.post('/api/combine', async (req, res) => {
|
||||
renderSub(c, parseInt(c.width) || 640, parseInt(c.height) || 420)
|
||||
));
|
||||
|
||||
let W, H, 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;
|
||||
}
|
||||
|
||||
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),源整图缩放到目标矩形
|
||||
let y = 0;
|
||||
scaled.forEach(s => {
|
||||
ctx.drawImage(s.img, 0, y * pixelRatio, s.w * pixelRatio, s.h * pixelRatio);
|
||||
y += s.h + gap;
|
||||
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 x = 0;
|
||||
scaled.forEach(s => {
|
||||
ctx.drawImage(s.img, x * pixelRatio, 0, s.w * pixelRatio, s.h * pixelRatio);
|
||||
x += s.w + gap;
|
||||
});
|
||||
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');
|
||||
@@ -1165,7 +1258,7 @@ app.post('/api/combine', async (req, res) => {
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': buffer.length,
|
||||
'X-Combine-Direction': direction,
|
||||
'X-Combine-Charts': String(scaled.length),
|
||||
'X-Combine-Charts': String(subs.length),
|
||||
'X-Chart-Width': W,
|
||||
'X-Chart-Height': H
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user