Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc8284d30f | ||
|
|
06cfbca2db | ||
|
|
0e15d43ed4 | ||
|
|
59788639cb | ||
|
|
cd7ffab71d |
@@ -3,7 +3,7 @@
|
||||
一个简洁强大的数据可视化工具,支持 **Web UI** 和 **API** 两种方式生成精美的对比图表和表格图片。
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
- **混合图** - 柱状图+折线图组合展示
|
||||
- **饼图** - 环形占比展示(第一列=名称,第一系列=数值)
|
||||
- **雷达图** - 多维度对比(第一列=维度,每个系列=一个多边形)
|
||||
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线)
|
||||
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线);开启双轴时**图例放在各自轴上方、各自往两边对齐**(左轴系列图例靠左、右轴系列图例靠右,附「左轴/右轴」分组标题),不再占用两侧空白区,绘图区占满整图宽度,看图一目了然
|
||||
- **X轴设置** - 可填写**横坐标轴名**,**轴名位置可选**(右侧末端 / 轴下方居中),选轴名时自动预留空间不再截字;刻度文字方向**默认水平(0°)**,支持**下拉档位(每10°)** + **手动输入任意度数(0-90°)**,解决长类别名重叠问题
|
||||
- **数据方向(行=系列)** - 支持"行=系列"布局:第一行是横坐标,每行一个系列,每行可独立设置图表类型(柱状/折线)与左右轴量度(如:营收行=柱状左轴,销量行=折线右轴)
|
||||
|
||||
### 📋 表格功能
|
||||
@@ -36,7 +37,7 @@
|
||||
### 🖼️ 多图合并
|
||||
- 将多张图表合并到一张图片中,适合对比/汇总场景,**默认2张,可一键不断添加**(支持删除)
|
||||
- 支持**横排(单行)**、**竖排(单列)**、**多行多列网格**(可设每行列数),一键切换实时预览
|
||||
- 每张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠,以及**数据方向(行=系列)**、**双Y轴(不同左右量度)**、**每系列独立图表类型与坐标轴**
|
||||
- 每张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠,以及**数据方向(行=系列)**、**双Y轴(不同左右量度)**、**X轴设置(轴名+刻度倾斜角)**、**每系列独立图表类型与坐标轴**
|
||||
- **图片大标题** - 可编写整张大图的标题,居中显示在顶部(可留空)
|
||||
- **共用图例** - 图例方式可选:每个小图各自图例 / 共用一套图例放顶部 / 共用一套图例放底部;共用时所有小图的系列合并去重,同名系列只显示一次,各小图隐藏自己的图例
|
||||
- 支持导出 PNG
|
||||
|
||||
@@ -291,8 +291,9 @@ function updateChart() {
|
||||
const dualAxis = document.getElementById('dualAxis').checked;
|
||||
const leftAxisName = document.getElementById('leftAxisName').value;
|
||||
const rightAxisName = document.getElementById('rightAxisName').value;
|
||||
const xAxisName = document.getElementById('xAxisName').value;
|
||||
const xLabelRotate = parseInt(document.getElementById('xLabelRotate').value) || 0;
|
||||
const xAxisName = document.getElementById('xAxisName').value.trim();
|
||||
const xAxisNameLoc = document.getElementById('xAxisNameLoc').value;
|
||||
const xLabelRotate = xLabelRotateValue();
|
||||
|
||||
// 饼图/雷达图:走专用构建逻辑(无坐标轴/网格,不支持双轴/堆叠/分割)
|
||||
if (chartType === 'pie' || chartType === 'radar') {
|
||||
@@ -462,34 +463,42 @@ function updateChart() {
|
||||
window._splitConfig = null;
|
||||
}
|
||||
|
||||
// 图表配置:双Y轴图例按左右轴分组放到对应轴边上,grid 相应让位
|
||||
let legendConfig, gridLeftCfg, gridRightCfg;
|
||||
// 图表配置:双Y轴图例放到各自轴上方(左图例靠左、右图例靠右),不再占用两侧空白,grid 全宽可用
|
||||
let legendConfig, gridLeftCfg, gridRightCfg, dualGridTop = null;
|
||||
if (dualAxis && showLegend) {
|
||||
const left = [], right = [];
|
||||
seriesOrder.forEach(origIdx => {
|
||||
const name = parsedData.seriesNames[origIdx];
|
||||
(seriesAxis[origIdx] === 1 ? right : left).push(name);
|
||||
});
|
||||
const legendTop = title ? 50 : 16;
|
||||
const chartW = chartInstance ? chartInstance.getWidth() : 800;
|
||||
const availW = Math.max(100, (chartW - 20) / 2);
|
||||
const rows = Math.max(estimateLegendRows(left, 12, availW), estimateLegendRows(right, 12, availW));
|
||||
const legendH = rows * 22 + 6; // 每行约 22px
|
||||
legendConfig = [
|
||||
{
|
||||
show: left.length > 0,
|
||||
orient: 'vertical', left: 8,
|
||||
top: title ? 56 : 24,
|
||||
orient: 'horizontal', left: 8,
|
||||
top: legendTop,
|
||||
width: '49%',
|
||||
data: left,
|
||||
title: left.length > 1 ? { text: '左轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined,
|
||||
textStyle: { color: textColor, fontSize: 12 }, itemGap: 10, icon: 'roundRect'
|
||||
textStyle: { color: textColor, fontSize: 12 }, itemGap: 14, icon: 'roundRect'
|
||||
},
|
||||
{
|
||||
show: right.length > 0,
|
||||
orient: 'vertical', right: 8,
|
||||
top: title ? 56 : 24,
|
||||
orient: 'horizontal', right: 8,
|
||||
top: legendTop,
|
||||
width: '49%',
|
||||
data: right,
|
||||
title: right.length > 1 ? { text: '右轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined,
|
||||
textStyle: { color: textColor, fontSize: 12 }, itemGap: 10, icon: 'roundRect'
|
||||
textStyle: { color: textColor, fontSize: 12 }, itemGap: 14, icon: 'roundRect'
|
||||
}
|
||||
];
|
||||
gridLeftCfg = measureLegendWidth(left) + 30;
|
||||
gridRightCfg = measureLegendWidth(right) + 30;
|
||||
gridLeftCfg = '3%';
|
||||
gridRightCfg = '4%';
|
||||
dualGridTop = legendTop + legendH + 8;
|
||||
} else {
|
||||
legendConfig = showLegend ? {
|
||||
show: true,
|
||||
@@ -500,6 +509,20 @@ function updateChart() {
|
||||
} : { show: false };
|
||||
gridLeftCfg = '3%';
|
||||
gridRightCfg = '4%';
|
||||
if (dualAxis) dualGridTop = title ? 52 : 24;
|
||||
}
|
||||
|
||||
// X轴名占位:右侧末端需加右 padding,轴下方居中需加下 padding(避免文字被截断)
|
||||
let gridBottomCfg = '3%';
|
||||
if (xAxisName) {
|
||||
const chartW = chartInstance ? chartInstance.getWidth() : 800;
|
||||
const nameWidth = measureTextWidth(xAxisName, 12);
|
||||
if (xAxisNameLoc === 'end') {
|
||||
const curRight = typeof gridRightCfg === 'number' ? gridRightCfg : 0.04 * chartW;
|
||||
gridRightCfg = curRight + nameWidth + 26; // nameGap(15) + 边距
|
||||
} else if (xAxisNameLoc === 'middle') {
|
||||
gridBottomCfg = 0.03 * chartW + 32;
|
||||
}
|
||||
}
|
||||
|
||||
const option = {
|
||||
@@ -539,13 +562,14 @@ function updateChart() {
|
||||
grid: {
|
||||
left: gridLeftCfg,
|
||||
right: gridRightCfg,
|
||||
bottom: '3%',
|
||||
top: dualAxis ? (title ? 52 : 24) : (showLegend ? (title ? 90 : 60) : (title ? 60 : 30)),
|
||||
bottom: gridBottomCfg,
|
||||
top: dualGridTop !== null ? dualGridTop : (showLegend ? (title ? 90 : 60) : (title ? 60 : 30)),
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
name: xAxisName,
|
||||
nameLocation: xAxisNameLoc,
|
||||
nameGap: 20,
|
||||
nameTextStyle: { color: textColor, fontSize: 12, fontWeight: 600 },
|
||||
data: parsedData.categories,
|
||||
@@ -789,17 +813,62 @@ function updateSeriesAxis(origIdx, axis) {
|
||||
updateChart();
|
||||
}
|
||||
|
||||
// ===== X轴刻度文字方向:下拉(10°档) + 自定义手动输入 =====
|
||||
function xLabelRotateValue() {
|
||||
const sel = document.getElementById('xLabelRotate');
|
||||
if (sel && sel.value === 'custom') {
|
||||
return parseInt(document.getElementById('xLabelRotateCustom').value) || 0;
|
||||
}
|
||||
return sel ? (parseInt(sel.value) || 0) : 0;
|
||||
}
|
||||
|
||||
function onXLabelRotateChange() {
|
||||
const sel = document.getElementById('xLabelRotate');
|
||||
const custom = document.getElementById('xLabelRotateCustom');
|
||||
if (custom) custom.style.display = (sel && sel.value === 'custom') ? 'block' : 'none';
|
||||
updateChart();
|
||||
}
|
||||
|
||||
function onXLabelRotateCustomInput() {
|
||||
const v = parseInt(document.getElementById('xLabelRotateCustom').value) || 0;
|
||||
const sel = document.getElementById('xLabelRotate');
|
||||
const custom = document.getElementById('xLabelRotateCustom');
|
||||
// 输入值命中 10° 档位时自动同步下拉显示
|
||||
if (v >= 0 && v <= 90 && v % 10 === 0) {
|
||||
sel.value = String(v);
|
||||
if (custom) custom.style.display = 'none';
|
||||
} else {
|
||||
sel.value = 'custom';
|
||||
if (custom) custom.style.display = 'block';
|
||||
}
|
||||
updateChart();
|
||||
}
|
||||
|
||||
// ===== 工具函数 =====
|
||||
// 测量图例文字宽度(用于双轴侧边图例的 grid 让位)
|
||||
function measureLegendWidth(names, fontSize) {
|
||||
// 测量文字宽度(用于轴名/图例占位计算)
|
||||
function measureTextWidth(text, fontSize) {
|
||||
fontSize = fontSize || 12;
|
||||
if (!text) return 0;
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.font = `${fontSize}px sans-serif`;
|
||||
return ctx.measureText(text).width;
|
||||
}
|
||||
|
||||
// 估算水平图例的换行行数(双轴顶部图例用,避免图例与绘图区重叠)
|
||||
function estimateLegendRows(names, fontSize, availWidth) {
|
||||
if (!names || !names.length) return 0;
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.font = `${fontSize}px sans-serif`;
|
||||
let max = 0;
|
||||
names.forEach(n => { const w = ctx.measureText(n).width; if (w > max) max = w; });
|
||||
return Math.ceil(max + 44); // 图标(14)+间距(6)+边距(24)
|
||||
const icon = 14, sw = 6, ig = 14; // 图标宽度 + 图标与文字间距 + itemGap
|
||||
let cur = 0, rows = 1;
|
||||
names.forEach(n => {
|
||||
const itemW = icon + sw + ctx.measureText(n).width;
|
||||
if (cur && cur + ig + itemW > availWidth) { rows++; cur = itemW; }
|
||||
else cur += itemW + (cur ? ig : 0);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function adjustColor(hex, amount) {
|
||||
@@ -1237,7 +1306,7 @@ function buildCombineChartOption(dataText, cfg) {
|
||||
showLegend = true, showGrid = true, showLabel = false,
|
||||
stackMode = false, smoothLine = true,
|
||||
dualYAxis = false, leftAxisName = '', rightAxisName = '', seriesConfig = null,
|
||||
xAxisName = '', xLabelRotate = 0
|
||||
xAxisName = '', xAxisNameLoc = 'end', xLabelRotate = 0, width = 600
|
||||
} = cfg;
|
||||
|
||||
const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff';
|
||||
@@ -1361,34 +1430,41 @@ function buildCombineChartOption(dataText, cfg) {
|
||||
return s;
|
||||
});
|
||||
|
||||
// 双Y轴图例按左右轴分组放到对应轴边上,grid 相应让位
|
||||
let legendCfg, gridLeftCfg, gridRightCfg;
|
||||
// 双Y轴图例放到各自轴上方(左靠左、右靠右),不再占用两侧空白
|
||||
let legendCfg, gridLeftCfg, gridRightCfg, dualGridTop = null;
|
||||
if (dualYAxis && showLegend) {
|
||||
const left = [], right = [];
|
||||
parsed.seriesNames.forEach(name => {
|
||||
const sc = scMap[name] || {};
|
||||
((sc.axis === 1 || sc.axis === '1') ? right : left).push(name);
|
||||
});
|
||||
const legendTop = title ? 42 : 12;
|
||||
const availW = Math.max(80, (width - 16) / 2);
|
||||
const rows = Math.max(estimateLegendRows(left, 11, availW), estimateLegendRows(right, 11, availW));
|
||||
const legendH = rows * 20 + 4; // 每行约 20px
|
||||
legendCfg = [
|
||||
{
|
||||
show: left.length > 0,
|
||||
orient: 'vertical', left: 6,
|
||||
top: title ? 44 : 12,
|
||||
orient: 'horizontal', left: 6,
|
||||
top: legendTop,
|
||||
width: '49%',
|
||||
data: left,
|
||||
title: left.length > 1 ? { text: '左轴', textStyle: { color: textColor, fontSize: 10, fontWeight: 600 } } : undefined,
|
||||
textStyle: { color: textColor, fontSize: 11 }, itemGap: 8, icon: 'roundRect'
|
||||
textStyle: { color: textColor, fontSize: 11 }, itemGap: 10, icon: 'roundRect'
|
||||
},
|
||||
{
|
||||
show: right.length > 0,
|
||||
orient: 'vertical', right: 6,
|
||||
top: title ? 44 : 12,
|
||||
orient: 'horizontal', right: 6,
|
||||
top: legendTop,
|
||||
width: '49%',
|
||||
data: right,
|
||||
title: right.length > 1 ? { text: '右轴', textStyle: { color: textColor, fontSize: 10, fontWeight: 600 } } : undefined,
|
||||
textStyle: { color: textColor, fontSize: 11 }, itemGap: 8, icon: 'roundRect'
|
||||
textStyle: { color: textColor, fontSize: 11 }, itemGap: 10, icon: 'roundRect'
|
||||
}
|
||||
];
|
||||
gridLeftCfg = Math.min(measureLegendWidth(left, 11) + 24, 200);
|
||||
gridRightCfg = Math.min(measureLegendWidth(right, 11) + 24, 200);
|
||||
gridLeftCfg = '3%';
|
||||
gridRightCfg = '4%';
|
||||
dualGridTop = legendTop + legendH + 6;
|
||||
} else {
|
||||
legendCfg = showLegend ? {
|
||||
show: true,
|
||||
@@ -1399,6 +1475,19 @@ function buildCombineChartOption(dataText, cfg) {
|
||||
} : { show: false };
|
||||
gridLeftCfg = '3%';
|
||||
gridRightCfg = '4%';
|
||||
if (dualYAxis) dualGridTop = title ? 44 : 16;
|
||||
}
|
||||
|
||||
// X轴名占位:右侧末端加右 padding,轴下方居中加下 padding(避免文字被截断)
|
||||
let gridBottomCfg = '3%';
|
||||
if (xAxisName) {
|
||||
const nameWidth = measureTextWidth(xAxisName, 11);
|
||||
if (xAxisNameLoc === 'end') {
|
||||
const curRight = typeof gridRightCfg === 'number' ? gridRightCfg : 0.04 * 800;
|
||||
gridRightCfg = curRight + nameWidth + 22;
|
||||
} else if (xAxisNameLoc === 'middle') {
|
||||
gridBottomCfg = 0.03 * 800 + 30;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1417,13 +1506,14 @@ function buildCombineChartOption(dataText, cfg) {
|
||||
},
|
||||
legend: legendCfg,
|
||||
grid: {
|
||||
left: gridLeftCfg, right: gridRightCfg, bottom: '3%',
|
||||
top: dualYAxis ? (title ? 44 : 16) : (showLegend ? (title ? 70 : 45) : (title ? 50 : 30)),
|
||||
left: gridLeftCfg, right: gridRightCfg, bottom: gridBottomCfg,
|
||||
top: dualGridTop !== null ? dualGridTop : (showLegend ? (title ? 70 : 45) : (title ? 50 : 30)),
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
name: xAxisName,
|
||||
nameLocation: xAxisNameLoc,
|
||||
nameGap: 16,
|
||||
nameTextStyle: { color: textColor, fontSize: 11, fontWeight: 600 },
|
||||
data: parsed.categories,
|
||||
@@ -1475,7 +1565,7 @@ function measureCombineChart(dataText, rowsAsSeries) {
|
||||
let combineCharts = [];
|
||||
|
||||
function defaultCombineChart() {
|
||||
return { title: '', type: 'bar', theme: 'default', legend: true, grid: true, label: false, stack: false, data: '', rowsAsSeries: false, dualYAxis: false, leftAxisName: '', rightAxisName: '', seriesConfig: [], xAxisName: '', xLabelRotate: 0 };
|
||||
return { title: '', type: 'bar', theme: 'default', legend: true, grid: true, label: false, stack: false, data: '', rowsAsSeries: false, dualYAxis: false, leftAxisName: '', rightAxisName: '', seriesConfig: [], xAxisName: '', xAxisNameLoc: 'end', xLabelRotate: 0 };
|
||||
}
|
||||
|
||||
function initCombineCharts() {
|
||||
@@ -1543,11 +1633,20 @@ function renderCombineCharts() {
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<div class="config-group">
|
||||
<label>X轴设置(轴名 · 刻度倾斜°)</label>
|
||||
<label>X轴设置</label>
|
||||
<div class="res-input-row" style="margin-bottom:8px">
|
||||
<input type="text" value="${escHtml(c.xAxisName || '')}" placeholder="轴名,如:月份" oninput="updateCombineChart(${i},'xAxisName',this.value)" style="flex:2">
|
||||
<select onchange="updateCombineChart(${i},'xAxisNameLoc',this.value)">
|
||||
<option value="end" ${c.xAxisNameLoc === 'middle' ? '' : 'selected'}>右侧末端</option>
|
||||
<option value="middle" ${c.xAxisNameLoc === 'middle' ? 'selected' : ''}>轴下方居中</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="res-input-row">
|
||||
<input type="text" value="${escHtml(c.xAxisName || '')}" placeholder="轴名,如:月份" oninput="updateCombineChart(${i},'xAxisName',this.value)">
|
||||
<span class="res-times">·</span>
|
||||
<input type="number" min="0" max="90" step="5" value="${c.xLabelRotate || 0}" style="width:64px" title="刻度文字倾斜角度(0-90°)" oninput="updateCombineChart(${i},'xLabelRotate',this.value)">
|
||||
<select id="combineRotateSel${i}" onchange="onCombineRotateSel(${i}, this.value)" style="flex:1" title="刻度文字方向">
|
||||
${[0,10,20,30,40,50,60,70,80,90].map(v => `<option value="${v}" ${String(v) === combineRotateSelValue(c.xLabelRotate) ? 'selected' : ''}>${v}°</option>`).join('')}
|
||||
<option value="custom" ${combineRotateSelValue(c.xLabelRotate) === 'custom' ? 'selected' : ''}>自定义…</option>
|
||||
</select>
|
||||
<input type="number" min="0" max="90" value="${c.xLabelRotate || 0}" id="combineRotateCustom${i}" style="width:58px;${combineRotateSelValue(c.xLabelRotate) === 'custom' ? '' : 'display:none'}" oninput="onCombineRotateCustom(${i}, this.value)" title="手动输入0-90°">
|
||||
<span class="res-times">°</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1627,6 +1726,45 @@ function updateCombineSeriesCfg(i, idx, field, value) {
|
||||
generateCombine();
|
||||
}
|
||||
|
||||
// ===== 多图合并:X轴刻度方向(下拉10°档 + 自定义输入) =====
|
||||
function combineRotateSelValue(v) {
|
||||
v = parseInt(v) || 0;
|
||||
return (v >= 0 && v <= 90 && v % 10 === 0) ? String(v) : 'custom';
|
||||
}
|
||||
|
||||
function combineRotateValue(i) {
|
||||
const sel = document.getElementById('combineRotateSel' + i);
|
||||
if (!sel) return 0;
|
||||
if (sel.value === 'custom') {
|
||||
const custom = document.getElementById('combineRotateCustom' + i);
|
||||
return parseInt(custom && custom.value) || 0;
|
||||
}
|
||||
return parseInt(sel.value) || 0;
|
||||
}
|
||||
|
||||
function onCombineRotateSel(i, val) {
|
||||
const custom = document.getElementById('combineRotateCustom' + i);
|
||||
if (custom) custom.style.display = val === 'custom' ? 'block' : 'none';
|
||||
if (combineCharts[i]) {
|
||||
combineCharts[i].xLabelRotate = combineRotateValue(i);
|
||||
generateCombine();
|
||||
}
|
||||
}
|
||||
|
||||
function onCombineRotateCustom(i, val) {
|
||||
const v = parseInt(val) || 0;
|
||||
const sel = document.getElementById('combineRotateSel' + i);
|
||||
const custom = document.getElementById('combineRotateCustom' + i);
|
||||
if (v >= 0 && v <= 90 && v % 10 === 0) {
|
||||
if (sel) sel.value = String(v);
|
||||
if (custom) custom.style.display = 'none';
|
||||
} else {
|
||||
if (sel) sel.value = 'custom';
|
||||
if (custom) custom.style.display = 'block';
|
||||
}
|
||||
if (combineCharts[i]) { combineCharts[i].xLabelRotate = v; generateCombine(); }
|
||||
}
|
||||
|
||||
// 多图合并:共享图例条目(所有子图系列合并,按名称去重)
|
||||
function buildCombineLegendItems() {
|
||||
const items = [];
|
||||
@@ -1685,8 +1823,8 @@ function generateCombine() {
|
||||
showLegend: sharedLegend ? false : c.legend, showGrid: c.grid, showLabel: c.label, stackMode: c.stack,
|
||||
rowsAsSeries: !!c.rowsAsSeries,
|
||||
dualYAxis: !!c.dualYAxis, leftAxisName: c.leftAxisName || '', rightAxisName: c.rightAxisName || '',
|
||||
xAxisName: c.xAxisName || '', xLabelRotate: parseInt(c.xLabelRotate) || 0,
|
||||
seriesConfig: c.seriesConfig
|
||||
xAxisName: c.xAxisName || '', xAxisNameLoc: c.xAxisNameLoc || 'end', xLabelRotate: parseInt(c.xLabelRotate) || 0,
|
||||
seriesConfig: c.seriesConfig, width: w
|
||||
});
|
||||
if (!option) { reject(new Error('数据格式错误')); return; }
|
||||
const holder = document.createElement('div');
|
||||
@@ -1855,7 +1993,8 @@ function collectChartConfig() {
|
||||
leftAxisName: document.getElementById('leftAxisName').value,
|
||||
rightAxisName: document.getElementById('rightAxisName').value,
|
||||
xAxisName: document.getElementById('xAxisName').value,
|
||||
xLabelRotate: parseInt(document.getElementById('xLabelRotate').value) || 0,
|
||||
xAxisNameLoc: document.getElementById('xAxisNameLoc').value,
|
||||
xLabelRotate: xLabelRotateValue(),
|
||||
enableSplit: document.getElementById('enableSplit').checked,
|
||||
splitIndex: parseInt(document.getElementById('splitIndex').value) || 3,
|
||||
leftLabel: document.getElementById('leftLabel').value,
|
||||
@@ -2063,7 +2202,19 @@ function applyFavoriteConfig(fav) {
|
||||
document.getElementById('leftAxisName').value = cfg.leftAxisName || '';
|
||||
document.getElementById('rightAxisName').value = cfg.rightAxisName || '';
|
||||
document.getElementById('xAxisName').value = cfg.xAxisName || '';
|
||||
document.getElementById('xLabelRotate').value = cfg.xLabelRotate || 0;
|
||||
document.getElementById('xAxisNameLoc').value = cfg.xAxisNameLoc || 'end';
|
||||
// 刻度方向:命中 10° 档位用下拉,否则用自定义输入
|
||||
const rotV = cfg.xLabelRotate || 0;
|
||||
const rotSel = document.getElementById('xLabelRotate');
|
||||
const rotCustom = document.getElementById('xLabelRotateCustom');
|
||||
if (rotV >= 0 && rotV <= 90 && rotV % 10 === 0) {
|
||||
rotSel.value = String(rotV);
|
||||
rotCustom.style.display = 'none';
|
||||
} else {
|
||||
rotSel.value = 'custom';
|
||||
rotCustom.value = rotV;
|
||||
rotCustom.style.display = 'block';
|
||||
}
|
||||
document.getElementById('enableSplit').checked = !!cfg.enableSplit;
|
||||
document.getElementById('splitIndex').value = cfg.splitIndex || 3;
|
||||
document.getElementById('leftLabel').value = cfg.leftLabel || '';
|
||||
|
||||
+31
-7
@@ -81,14 +81,38 @@ C, 20, 30, 40</pre>
|
||||
</div>
|
||||
|
||||
<div class="config-group">
|
||||
<label>X轴设置(轴名 · 刻度倾斜角度)</label>
|
||||
<div class="res-input-row">
|
||||
<input type="text" id="xAxisName" placeholder="横坐标轴名,如:月份" oninput="updateChart()">
|
||||
<span class="res-times">·</span>
|
||||
<input type="number" id="xLabelRotate" min="0" max="90" step="5" value="0" style="width:70px" title="刻度文字倾斜角度(0-90°)" oninput="updateChart()">
|
||||
<span class="res-times">°</span>
|
||||
<label>X轴设置</label>
|
||||
<div class="config-group">
|
||||
<label>横坐标轴名(可留空)</label>
|
||||
<input type="text" id="xAxisName" placeholder="如:月份 / 年份" oninput="updateChart()">
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>轴名位置</label>
|
||||
<select id="xAxisNameLoc" onchange="updateChart()">
|
||||
<option value="end">横坐标右侧末端</option>
|
||||
<option value="middle">横坐标轴下方居中</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>刻度文字方向(0°=水平 · 倾斜可防长名重叠)</label>
|
||||
<div class="res-input-row">
|
||||
<select id="xLabelRotate" onchange="onXLabelRotateChange()" style="flex:1">
|
||||
<option value="0">0° 水平</option>
|
||||
<option value="10">10°</option>
|
||||
<option value="20">20°</option>
|
||||
<option value="30">30°</option>
|
||||
<option value="40">40°</option>
|
||||
<option value="50">50°</option>
|
||||
<option value="60">60°</option>
|
||||
<option value="70">70°</option>
|
||||
<option value="80">80°</option>
|
||||
<option value="90">90° 竖直</option>
|
||||
<option value="custom">自定义…</option>
|
||||
</select>
|
||||
<input type="number" id="xLabelRotateCustom" min="0" max="90" value="0" style="width:70px;display:none" title="手动输入0-90°" oninput="onXLabelRotateCustomInput()">
|
||||
<span class="res-times">°</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint-text">轴名可留空不显示;刻度方向:0°=水平(默认),45°=右上倾斜,90°=竖直,实时生效</p>
|
||||
</div>
|
||||
|
||||
<div class="config-group">
|
||||
|
||||
@@ -146,7 +146,8 @@ function buildChartOption(params) {
|
||||
leftAxisName = '',
|
||||
rightAxisName = '',
|
||||
seriesTypes = null,
|
||||
seriesAxis = null
|
||||
seriesAxis = null,
|
||||
width = 800
|
||||
} = params;
|
||||
|
||||
const parsedData = parseData(data, params.rowsAsSeries);
|
||||
@@ -344,6 +345,58 @@ function buildChartOption(params) {
|
||||
};
|
||||
}
|
||||
|
||||
// 双Y轴:图例放到各自轴上方(左图例靠左、右图例靠右),不再占用两侧空白
|
||||
let legendOpt, gridTop;
|
||||
if (dualYAxis && showLegend) {
|
||||
const left = [], right = [];
|
||||
series.forEach(s => { if (s.yAxisIndex === 1) right.push(s.name); else left.push(s.name); });
|
||||
const legendTop = title ? 50 : 16;
|
||||
const availW = Math.max(100, (width - 20) / 2);
|
||||
// 粗略估算文字宽度:中文≈字号宽,ASCII≈字号*0.6
|
||||
const textW = (n) => [...String(n)].reduce((w, ch) => w + (ch.charCodeAt(0) > 255 ? 12 : 7), 0);
|
||||
const estRows = (names) => {
|
||||
if (!names || !names.length) return 0;
|
||||
const icon = 14, sw = 6, ig = 14;
|
||||
let cur = 0, rows = 1;
|
||||
names.forEach(n => {
|
||||
const itemW = icon + sw + textW(n);
|
||||
if (cur && cur + ig + itemW > availW) { rows++; cur = itemW; }
|
||||
else cur += itemW + (cur ? ig : 0);
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
const rows = Math.max(estRows(left), estRows(right));
|
||||
const legendH = rows * 22 + 6;
|
||||
legendOpt = [
|
||||
{
|
||||
show: left.length > 0,
|
||||
orient: 'horizontal', left: 8,
|
||||
top: legendTop, width: '49%',
|
||||
data: left,
|
||||
title: left.length > 1 ? { text: '左轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined,
|
||||
textStyle: { color: textColor, fontSize: 12 }, itemGap: 14, icon: 'roundRect'
|
||||
},
|
||||
{
|
||||
show: right.length > 0,
|
||||
orient: 'horizontal', right: 8,
|
||||
top: legendTop, width: '49%',
|
||||
data: right,
|
||||
title: right.length > 1 ? { text: '右轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined,
|
||||
textStyle: { color: textColor, fontSize: 12 }, itemGap: 14, icon: 'roundRect'
|
||||
}
|
||||
];
|
||||
gridTop = legendTop + legendH + 8;
|
||||
} else {
|
||||
legendOpt = showLegend ? {
|
||||
show: true,
|
||||
top: title ? 50 : 15,
|
||||
textStyle: { color: textColor, fontSize: 12 },
|
||||
itemGap: 20,
|
||||
icon: 'roundRect'
|
||||
} : { show: false };
|
||||
gridTop = showLegend ? (title ? 90 : 60) : (title ? 60 : 30);
|
||||
}
|
||||
|
||||
// 完整 option
|
||||
const option = {
|
||||
backgroundColor: bgColor,
|
||||
@@ -358,18 +411,12 @@ function buildChartOption(params) {
|
||||
}
|
||||
} : undefined,
|
||||
tooltip: { show: false },
|
||||
legend: showLegend ? {
|
||||
show: true,
|
||||
top: title ? 50 : 15,
|
||||
textStyle: { color: textColor, fontSize: 12 },
|
||||
itemGap: 20,
|
||||
icon: 'roundRect'
|
||||
} : { show: false },
|
||||
legend: legendOpt,
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
top: showLegend ? (title ? 90 : 60) : (title ? 60 : 30),
|
||||
top: gridTop,
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
@@ -1030,7 +1077,7 @@ app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'data-chart-tool',
|
||||
version: '1.16.0',
|
||||
version: '1.18.0',
|
||||
endpoints: {
|
||||
'POST /api/chart': '生成图表图片(JSON body)',
|
||||
'GET /api/chart': '生成图表图片(URL 参数)',
|
||||
@@ -1050,7 +1097,7 @@ app.get('/api/health', (req, res) => {
|
||||
app.get('/api/docs', (req, res) => {
|
||||
res.json({
|
||||
name: '数据可视化图表生成器 API',
|
||||
version: '1.16.0',
|
||||
version: '1.18.0',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -1282,6 +1329,7 @@ app.post('/api/combine', async (req, res) => {
|
||||
const renderSub = async (params, w, h) => {
|
||||
const p = { ...params };
|
||||
if (sharedLegend) p.showLegend = false;
|
||||
p.width = w; // 双轴顶部图例换行估算用实际子图宽度
|
||||
const option = buildChartOption(p);
|
||||
const c = createCanvas(w * pixelRatio, h * pixelRatio);
|
||||
const chart = echarts.init(c, null, {
|
||||
|
||||
Reference in New Issue
Block a user