feat: X轴名右侧不再截字+轴名位置可选(右侧/下方居中)+刻度方向下拉10°档+自定义手动输入 v1.17.1

This commit is contained in:
2026-08-23 10:09:14 +08:00
parent 59788639cb
commit 0e15d43ed4
3 changed files with 176 additions and 22 deletions
+2 -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.17.0-green.svg)
![Version](https://img.shields.io/badge/version-1.17.1-green.svg)
## ✨ 功能特性
@@ -14,7 +14,7 @@
- **饼图** - 环形占比展示(第一列=名称,第一系列=数值)
- **雷达图** - 多维度对比(第一列=维度,每个系列=一个多边形)
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线);开启双轴时**图例自动按左右轴分组,放到对应轴边上**(左轴系列图例在左侧、右轴系列图例在右侧,附「左轴/右轴」分组标题),看图一目了然
- **X轴设置** - 可填写**横坐标轴名**(如:月份);刻度文字方向可调,**默认水平(0°)**,支持倾斜显示且**度数可调(0-90°)**(如45°/90°),解决长类别名重叠问题
- **X轴设置** - 可填写**横坐标轴名**,**轴名位置可选**(右侧末端 / 轴下方居中),选轴名时自动预留空间不再截字;刻度文字方向**默认水平(0°)**,支持**下拉档位(每10°)** + **手动输入任意度数(0-90°)**,解决长类别名重叠问题
- **数据方向(行=系列)** - 支持"行=系列"布局:第一行是横坐标,每行一个系列,每行可独立设置图表类型(柱状/折线)与左右轴量度(如:营收行=柱状左轴,销量行=折线右轴)
### 📋 表格功能
+143 -13
View File
@@ -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') {
@@ -502,6 +503,19 @@ function updateChart() {
gridRightCfg = '4%';
}
// 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 = {
backgroundColor: bgColor,
title: title ? {
@@ -539,13 +553,14 @@ function updateChart() {
grid: {
left: gridLeftCfg,
right: gridRightCfg,
bottom: '3%',
bottom: gridBottomCfg,
top: dualAxis ? (title ? 52 : 24) : (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,7 +804,48 @@ 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();
}
// ===== 工具函数 =====
// 测量文字宽度(用于轴名/图例占位计算)
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;
}
// 测量图例文字宽度(用于双轴侧边图例的 grid 让位)
function measureLegendWidth(names, fontSize) {
fontSize = fontSize || 12;
@@ -1237,7 +1293,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
} = cfg;
const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff';
@@ -1401,6 +1457,18 @@ function buildCombineChartOption(dataText, cfg) {
gridRightCfg = '4%';
}
// 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 {
backgroundColor: bgColor,
title: title ? {
@@ -1417,13 +1485,14 @@ function buildCombineChartOption(dataText, cfg) {
},
legend: legendCfg,
grid: {
left: gridLeftCfg, right: gridRightCfg, bottom: '3%',
left: gridLeftCfg, right: gridRightCfg, bottom: gridBottomCfg,
top: dualYAxis ? (title ? 44 : 16) : (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 +1544,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 +1612,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 +1705,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,7 +1802,7 @@ 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,
xAxisName: c.xAxisName || '', xAxisNameLoc: c.xAxisNameLoc || 'end', xLabelRotate: parseInt(c.xLabelRotate) || 0,
seriesConfig: c.seriesConfig
});
if (!option) { reject(new Error('数据格式错误')); return; }
@@ -1855,7 +1972,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 +2181,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
View File
@@ -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">