feat: 双Y轴图例放到各自轴上方靠两边对齐,不再占用两侧空白区 v1.18.0

- 主图表/多图合并/服务端API 三处统一:左轴图例横排靠左、右轴图例横排靠右,放在各自轴上方
- grid 左右不再为侧边图例让位,绘图区占满整图宽度
- 按实际宽度估算图例换行行数,grid 顶部相应让位避免重叠
This commit is contained in:
2026-08-23 11:06:32 +08:00
parent 06cfbca2db
commit bc8284d30f
3 changed files with 112 additions and 43 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.1-green.svg)
![Version](https://img.shields.io/badge/version-1.18.0-green.svg)
## ✨ 功能特性
@@ -13,7 +13,7 @@
- **混合图** - 柱状图+折线图组合展示
- **饼图** - 环形占比展示(第一列=名称,第一系列=数值)
- **雷达图** - 多维度对比(第一列=维度,每个系列=一个多边形)
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线);开启双轴时**图例自动按左右轴分组,放到对应轴边上**(左轴系列图例在左侧、右轴系列图例在右侧,附「左轴/右轴」分组标题),看图一目了然
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线);开启双轴时**图例放在各自轴上方、各自往两边对齐**(左轴系列图例靠左、右轴系列图例靠右,附「左轴/右轴」分组标题),不再占用两侧空白区,绘图区占满整图宽度,看图一目了然
- **X轴设置** - 可填写**横坐标轴名**,**轴名位置可选**(右侧末端 / 轴下方居中),选轴名时自动预留空间不再截字;刻度文字方向**默认水平(0°)**,支持**下拉档位(每10°)** + **手动输入任意度数(0-90°)**,解决长类别名重叠问题
- **数据方向(行=系列)** - 支持"行=系列"布局:第一行是横坐标,每行一个系列,每行可独立设置图表类型(柱状/折线)与左右轴量度(如:营收行=柱状左轴,销量行=折线右轴)
+51 -30
View File
@@ -463,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,
@@ -501,6 +509,7 @@ function updateChart() {
} : { show: false };
gridLeftCfg = '3%';
gridRightCfg = '4%';
if (dualAxis) dualGridTop = title ? 52 : 24;
}
// X轴名占位:右侧末端需加右 padding,轴下方居中需加下 padding(避免文字被截断)
@@ -554,7 +563,7 @@ function updateChart() {
left: gridLeftCfg,
right: gridRightCfg,
bottom: gridBottomCfg,
top: dualAxis ? (title ? 52 : 24) : (showLegend ? (title ? 90 : 60) : (title ? 60 : 30)),
top: dualGridTop !== null ? dualGridTop : (showLegend ? (title ? 90 : 60) : (title ? 60 : 30)),
containLabel: true
},
xAxis: {
@@ -846,16 +855,20 @@ function measureTextWidth(text, fontSize) {
return ctx.measureText(text).width;
}
// 测量图例文字宽度(用于双轴侧边图例的 grid 让位
function measureLegendWidth(names, fontSize) {
fontSize = fontSize || 12;
// 估算水平图例的换行行数(双轴顶部图例用,避免图例与绘图区重叠
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) {
@@ -1293,7 +1306,7 @@ function buildCombineChartOption(dataText, cfg) {
showLegend = true, showGrid = true, showLabel = false,
stackMode = false, smoothLine = true,
dualYAxis = false, leftAxisName = '', rightAxisName = '', seriesConfig = null,
xAxisName = '', xAxisNameLoc = 'end', xLabelRotate = 0
xAxisName = '', xAxisNameLoc = 'end', xLabelRotate = 0, width = 600
} = cfg;
const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff';
@@ -1417,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,
@@ -1455,6 +1475,7 @@ function buildCombineChartOption(dataText, cfg) {
} : { show: false };
gridLeftCfg = '3%';
gridRightCfg = '4%';
if (dualYAxis) dualGridTop = title ? 44 : 16;
}
// X轴名占位:右侧末端加右 padding,轴下方居中加下 padding(避免文字被截断)
@@ -1486,7 +1507,7 @@ function buildCombineChartOption(dataText, cfg) {
legend: legendCfg,
grid: {
left: gridLeftCfg, right: gridRightCfg, bottom: gridBottomCfg,
top: dualYAxis ? (title ? 44 : 16) : (showLegend ? (title ? 70 : 45) : (title ? 50 : 30)),
top: dualGridTop !== null ? dualGridTop : (showLegend ? (title ? 70 : 45) : (title ? 50 : 30)),
containLabel: true
},
xAxis: {
@@ -1803,7 +1824,7 @@ function generateCombine() {
rowsAsSeries: !!c.rowsAsSeries,
dualYAxis: !!c.dualYAxis, leftAxisName: c.leftAxisName || '', rightAxisName: c.rightAxisName || '',
xAxisName: c.xAxisName || '', xAxisNameLoc: c.xAxisNameLoc || 'end', xLabelRotate: parseInt(c.xLabelRotate) || 0,
seriesConfig: c.seriesConfig
seriesConfig: c.seriesConfig, width: w
});
if (!option) { reject(new Error('数据格式错误')); return; }
const holder = document.createElement('div');
+59 -11
View File
@@ -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.17.1',
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.17.1',
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, {