Compare commits

..
7 Commits
Author SHA1 Message Date
hz4th_coder 01c0536986 feat: 数据标签颜色默认同系列+可自定义;Y轴最小/最大值手动指定 v1.20.0
- 数据标签颜色:默认自动取系列颜色(柱状/折线色),也可选自定义颜色手动指定
- Y轴范围:默认自动计算,可手动指定最小/最大值;双Y轴时左/右轴可分别指定
- 主图 + 多图合并 + 服务端API 三处统一;收藏保存/恢复含两项设置
- API 新增参数 labelColorMode/labelColor, yAxisMin/yAxisMax, leftAxisMin/Max, rightAxisMin/Max
2026-08-23 18:15:21 +08:00
hz4th_coder 864c43ab99 feat: 图例上移不遮轴名+按类型显示图标+系列样式细分+轴标题竖直/顶部+X轴名默认取首字段放轴下 v1.19.0
- 双轴图例上移到更靠上位置,轴名在顶部模式时 grid 自动多让位,不再遮住左右轴名
- 图例图标按系列类型自动(柱状=方块、折线=线条),去掉固定 roundRect
- 系列样式细分:柱状实体/空心/阴影,折线实线/虚线/点线(主图+多图合并+服务端API 三处统一)
- 左右轴标题显示方式可选:竖直显示(默认)/轴上部横排,双轴图主图/合并/API 均支持
- X轴名默认自动取数据首列字段名,默认放横坐标轴下方居中;用户手动编辑后不再自动覆盖
- 收藏保存/恢复含系列样式与轴名显示方式
2026-08-23 15:30:36 +08:00
hz4th_coder bc8284d30f feat: 双Y轴图例放到各自轴上方靠两边对齐,不再占用两侧空白区 v1.18.0
- 主图表/多图合并/服务端API 三处统一:左轴图例横排靠左、右轴图例横排靠右,放在各自轴上方
- grid 左右不再为侧边图例让位,绘图区占满整图宽度
- 按实际宽度估算图例换行行数,grid 顶部相应让位避免重叠
2026-08-23 11:06:32 +08:00
hz4th_coder 06cfbca2db chore: 版本号更新至 v1.17.1 2026-08-23 10:09:34 +08:00
hz4th_coder 0e15d43ed4 feat: X轴名右侧不再截字+轴名位置可选(右侧/下方居中)+刻度方向下拉10°档+自定义手动输入 v1.17.1 2026-08-23 10:09:14 +08:00
hz4th_coder 59788639cb chore: 版本号更新至 v1.17.0 2026-08-23 09:51:59 +08:00
hz4th_coder cd7ffab71d docs: README 更新 v1.17.0 功能说明 2026-08-23 09:51:46 +08:00
4 changed files with 670 additions and 84 deletions
+7 -3
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.16.0-green.svg)
![Version](https://img.shields.io/badge/version-1.20.0-green.svg)
## ✨ 功能特性
@@ -13,7 +13,11 @@
- **混合图** - 柱状图+折线图组合展示
- **饼图** - 环形占比展示(第一列=名称,第一系列=数值)
- **雷达图** - 多维度对比(第一列=维度,每个系列=一个多边形)
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线)
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线);开启双轴时**图例放在各自轴上方、各自往两边对齐**(左轴系列图例靠左、右轴系列图例靠右,附「左轴/右轴」分组标题),不再占用两侧空白区,绘图区占满整图宽度;**图例图标随系列类型自动变化**(柱状=方块、折线=线条);**左右轴标题显示方式可选**(竖直显示/轴上部横排)
- **系列样式细分** - 每个系列可单独设置图表类型与样式:**柱状**分实体/空心/阴影,**折线**分实线/虚线/点线,图例与样式联动
- **数据标签颜色** - 显示数据标签时颜色**默认同系列颜色**(柱状/折线色),也可手动指定自定义颜色
- **Y轴范围** - 默认自动计算,也可**手动指定 Y 轴最小/最大值**;双Y轴时左轴、右轴可分别指定范围
- **X轴设置** - 横坐标轴名**默认自动取数据首列字段名**,**默认放在横坐标轴下方居中**(也可选右侧末端);选轴名时自动预留空间不再截字;刻度文字方向**默认水平(0°)**,支持**下拉档位(每10°)** + **手动输入任意度数(0-90°)**,解决长类别名重叠问题
- **数据方向(行=系列)** - 支持"行=系列"布局:第一行是横坐标,每行一个系列,每行可独立设置图表类型(柱状/折线)与左右轴量度(如:营收行=柱状左轴,销量行=折线右轴)
### 📋 表格功能
@@ -36,7 +40,7 @@
### 🖼️ 多图合并
- 将多张图表合并到一张图片中,适合对比/汇总场景,**默认2张,可一键不断添加**(支持删除)
- 支持**横排(单行)**、**竖排(单列)**、**多行多列网格**(可设每行列数),一键切换实时预览
- 每张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠,以及**数据方向(行=系列)**、**双Y轴(不同左右量度)**、**每系列独立图表类型与坐标轴**
- 每张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠,以及**数据方向(行=系列)**、**双Y轴(不同左右量度)**、**X轴设置(轴名+刻度倾斜角)**、**每系列独立图表类型与坐标轴**
- **图片大标题** - 可编写整张大图的标题,居中显示在顶部(可留空)
- **共用图例** - 图例方式可选:每个小图各自图例 / 共用一套图例放顶部 / 共用一套图例放底部;共用时所有小图的系列合并去重,同名系列只显示一次,各小图隐藏自己的图例
- 支持导出 PNG
+439 -59
View File
@@ -122,7 +122,8 @@ function parseData(rawText, rowsAsSeries) {
return {
categories,
seriesNames: names,
seriesData
seriesData,
firstField: headers[0] || ''
};
}
@@ -142,7 +143,8 @@ function parseData(rawText, rowsAsSeries) {
return {
categories,
seriesNames: headers.slice(1),
seriesData
seriesData,
firstField: headers[0] || ''
};
}
@@ -157,6 +159,9 @@ function generateChart() {
parsedData = parseData(rawText, document.getElementById('dataOrientation').value === 'rows');
if (!parsedData) return;
// X轴名默认取数据首列字段名(用户手动编辑过则保持不动)
setXAxisNameDefault(parsedData.firstField);
// 初始化系列顺序和颜色
seriesOrder = parsedData.seriesNames.map((_, i) => i);
const palette = colorPalettes[document.getElementById('themeStyle').value] || colorPalettes.default;
@@ -291,8 +296,30 @@ 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 leftAxisNameLoc = (document.getElementById('leftAxisNameLoc') || {}).value || 'vertical';
const rightAxisNameLoc = (document.getElementById('rightAxisNameLoc') || {}).value || 'vertical';
const xAxisName = document.getElementById('xAxisName').value.trim();
const xAxisNameLoc = document.getElementById('xAxisNameLoc').value;
const xLabelRotate = xLabelRotateValue();
// 数据标签颜色:auto=同系列颜色 / custom=手动指定
const labelColorMode = (document.getElementById('labelColorMode') || {}).value || 'auto';
const labelColor = (document.getElementById('labelColor') || {}).value || '#333333';
// Y轴范围(留空=自动)
const yAxisMin = (document.getElementById('yAxisMin') || {}).value;
const yAxisMax = (document.getElementById('yAxisMax') || {}).value;
const leftAxisMin = (document.getElementById('leftAxisMin') || {}).value;
const leftAxisMax = (document.getElementById('leftAxisMax') || {}).value;
const rightAxisMin = (document.getElementById('rightAxisMin') || {}).value;
const rightAxisMax = (document.getElementById('rightAxisMax') || {}).value;
// 显示/隐藏:数据标签颜色 + 单/双轴范围输入
const lcGroup = document.getElementById('labelColorGroup');
if (lcGroup) lcGroup.style.display = showLabel ? 'block' : 'none';
const singleR = document.getElementById('singleAxisRange');
const dualR = document.getElementById('dualAxisRange');
if (singleR) singleR.style.display = dualAxis ? 'none' : 'block';
if (dualR) dualR.style.display = dualAxis ? 'block' : 'none';
// 饼图/雷达图:走专用构建逻辑(无坐标轴/网格,不支持双轴/堆叠/分割)
if (chartType === 'pie' || chartType === 'radar') {
@@ -371,13 +398,17 @@ function updateChart() {
}
}
// 数据标签
// 系列细分样式:柱状(实体/空心/阴影) / 折线(实线/虚线/点线)
const styleOv = window.seriesStyleOverrides && window.seriesStyleOverrides[origIdx];
applySeriesStyle(seriesItem, type, color, styleOv);
// 数据标签:颜色默认同系列颜色,可手动自定义
if (showLabel) {
seriesItem.label = {
show: true,
position: type === 'bar' ? 'top' : 'top',
fontSize: 11,
color: textColor,
color: labelColorMode === 'custom' ? labelColor : color,
formatter: (params) => {
if (params.value >= 10000) return (params.value / 10000).toFixed(1) + 'w';
if (params.value >= 1000) return (params.value / 1000).toFixed(1) + 'k';
@@ -462,44 +493,68 @@ 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 ? 42 : 8;
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
// 轴名在顶部时需额外留空间,避免被图例遮挡;竖直显示时不占顶部空间
const needNameTopGap = (leftAxisNameLoc === 'top' || rightAxisNameLoc === 'top');
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
},
{
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
}
];
gridLeftCfg = measureLegendWidth(left) + 30;
gridRightCfg = measureLegendWidth(right) + 30;
gridLeftCfg = '3%';
gridRightCfg = '4%';
dualGridTop = legendTop + legendH + (needNameTopGap ? 24 : 8);
} else {
legendConfig = showLegend ? {
show: true,
top: title ? 50 : 15,
top: title ? 44 : 10,
textStyle: { color: textColor, fontSize: 12 },
itemGap: 20,
icon: 'roundRect'
itemGap: 20
} : { show: false };
gridLeftCfg = '3%';
gridRightCfg = '4%';
if (dualAxis) dualGridTop = title ? 46 : 20;
}
// 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 +594,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,
@@ -562,7 +618,12 @@ function updateChart() {
{
type: 'value',
name: leftAxisName || '左轴',
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] },
min: axisNum(leftAxisMin),
max: axisNum(leftAxisMax),
nameLocation: leftAxisNameLoc === 'top' ? 'end' : 'middle',
nameRotate: leftAxisNameLoc === 'top' ? 0 : 90,
nameGap: leftAxisNameLoc === 'top' ? 12 : 32,
nameTextStyle: { color: textColor, fontSize: 11 },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
@@ -575,7 +636,12 @@ function updateChart() {
type: 'value',
name: rightAxisName || '右轴',
position: 'right',
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 4, 0, 0] },
min: axisNum(rightAxisMin),
max: axisNum(rightAxisMax),
nameLocation: rightAxisNameLoc === 'top' ? 'end' : 'middle',
nameRotate: rightAxisNameLoc === 'top' ? 0 : 90,
nameGap: rightAxisNameLoc === 'top' ? 12 : 32,
nameTextStyle: { color: textColor, fontSize: 11 },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
@@ -583,6 +649,8 @@ function updateChart() {
}
] : {
type: 'value',
min: axisNum(yAxisMin),
max: axisNum(yAxisMax),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
@@ -676,6 +744,19 @@ function renderSeriesConfig() {
</select>
` : '';
// 系列样式细分:柱状(实体/空心/阴影),折线(实线/虚线/点线),随类型联动
const typeOv = window.seriesTypeOverrides && window.seriesTypeOverrides[origIdx];
const styleOv = (window.seriesStyleOverrides && window.seriesStyleOverrides[origIdx]) || 'solid';
const effType = (typeOv && typeOv !== 'auto') ? typeOv : 'bar';
const styleOptions = effType === 'line'
? [['solid', '实线'], ['dashed', '虚线'], ['dotted', '点线']]
: [['solid', '实体'], ['hollow', '空心'], ['shadow', '阴影']];
const styleSelect = `
<select onchange="updateSeriesStyle(${origIdx}, this.value)" title="系列样式">
${styleOptions.map(([v, l]) => `<option value="${v}" ${styleOv === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
`;
const item = document.createElement('div');
item.className = 'series-item';
item.draggable = true;
@@ -690,6 +771,7 @@ function renderSeriesConfig() {
<option value="bar">柱状</option>
<option value="line">折线</option>
</select>
${styleSelect}
${axisSelect}
`;
@@ -773,6 +855,13 @@ function updateSeriesType(origIdx, type) {
// 存储自定义类型覆盖
if (!window.seriesTypeOverrides) window.seriesTypeOverrides = {};
window.seriesTypeOverrides[origIdx] = type;
renderSeriesConfig(); // 样式选项随柱状/折线联动
updateChart();
}
function updateSeriesStyle(origIdx, style) {
if (!window.seriesStyleOverrides) window.seriesStyleOverrides = {};
window.seriesStyleOverrides[origIdx] = style;
updateChart();
}
@@ -780,6 +869,10 @@ function updateSeriesType(origIdx, type) {
function onDualAxisChange() {
const on = document.getElementById('dualAxis').checked;
document.getElementById('rightAxisGroup').style.display = on ? 'block' : 'none';
const singleR = document.getElementById('singleAxisRange');
const dualR = document.getElementById('dualAxisRange');
if (singleR) singleR.style.display = on ? 'none' : 'block';
if (dualR) dualR.style.display = on ? 'block' : 'none';
renderSeriesConfig(); // 显示/隐藏系列轴选择
updateChart();
}
@@ -789,17 +882,119 @@ 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 hexToRgba(hex, alpha) {
hex = String(hex || '#888').replace('#', '');
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
const num = parseInt(hex, 16);
return `rgba(${(num >> 16) & 255},${(num >> 8) & 255},${num & 255},${alpha})`;
}
// 系列细分样式:柱状(实体/空心/阴影),折线(实线/虚线/点线)
function applySeriesStyle(seriesItem, type, color, style) {
if (!style || style === 'solid') return;
if (type === 'bar') {
if (style === 'hollow') {
const fill = (typeof color === 'string' && color.charAt(0) === '#') ? hexToRgba(color, 0.12) : 'rgba(120,120,120,0.12)';
seriesItem.itemStyle.color = fill;
seriesItem.itemStyle.borderColor = (typeof color === 'string' && color.charAt(0) === '#') ? color : '#888';
seriesItem.itemStyle.borderWidth = 2;
} else if (style === 'shadow') {
seriesItem.itemStyle.shadowBlur = 14;
seriesItem.itemStyle.shadowColor = (typeof color === 'string' && color.charAt(0) === '#') ? hexToRgba(color, 0.55) : 'rgba(0,0,0,0.35)';
seriesItem.itemStyle.shadowOffsetY = 4;
}
} else if (type === 'line') {
if (!seriesItem.lineStyle) seriesItem.lineStyle = { width: 3 };
if (style === 'dashed') seriesItem.lineStyle.type = 'dashed';
else if (style === 'dotted') seriesItem.lineStyle.type = 'dotted';
}
}
// ===== X轴名:默认取数据首列字段名,用户手动编辑后不再自动覆盖 =====
let xAxisNameTouched = false;
function onXAxisNameInput() {
xAxisNameTouched = true;
updateChart();
}
function setXAxisNameDefault(firstField) {
const input = document.getElementById('xAxisName');
if (!input) return;
if (!xAxisNameTouched && firstField && input.value.trim() !== firstField) {
input.value = firstField;
}
}
// Y轴 min/max:空值或非法返回 undefined(走 ECharts 自动)
function axisNum(v) {
if (v === '' || v === undefined || v === null) return undefined;
const n = parseFloat(v);
return isNaN(n) ? undefined : n;
}
// 数据标签颜色模式切换:auto=同系列颜色 / custom=自定义色
function onLabelColorModeChange() {
const colorInput = document.getElementById('labelColor');
if (colorInput) colorInput.style.display = document.getElementById('labelColorMode').value === 'custom' ? 'block' : 'none';
updateChart();
}
function adjustColor(hex, amount) {
@@ -1237,7 +1432,10 @@ function buildCombineChartOption(dataText, cfg) {
showLegend = true, showGrid = true, showLabel = false,
stackMode = false, smoothLine = true,
dualYAxis = false, leftAxisName = '', rightAxisName = '', seriesConfig = null,
xAxisName = '', xLabelRotate = 0
leftAxisNameLoc = 'vertical', rightAxisNameLoc = 'vertical',
labelColorMode = 'auto', labelColor = '#333333',
yAxisMin = '', yAxisMax = '', leftAxisMin = '', leftAxisMax = '', rightAxisMin = '', rightAxisMax = '',
xAxisName = '', xAxisNameLoc = 'middle', xLabelRotate = 0, width = 600
} = cfg;
const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff';
@@ -1344,12 +1542,15 @@ function buildCombineChartOption(dataText, cfg) {
}
}
// 系列细分样式:柱状(实体/空心/阴影) / 折线(实线/虚线/点线)
applySeriesStyle(s, type, color, sc.style);
if (showLabel) {
s.label = {
show: true,
position: 'top',
fontSize: 11,
color: textColor,
color: labelColorMode === 'custom' ? labelColor : color,
formatter: (p) => {
if (p.value >= 10000) return (p.value / 10000).toFixed(1) + 'w';
if (p.value >= 1000) return (p.value / 1000).toFixed(1) + 'k';
@@ -1361,44 +1562,64 @@ 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 ? 38 : 8;
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
const needNameTopGap = (leftAxisNameLoc === 'top' || rightAxisNameLoc === 'top');
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
},
{
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
}
];
gridLeftCfg = Math.min(measureLegendWidth(left, 11) + 24, 200);
gridRightCfg = Math.min(measureLegendWidth(right, 11) + 24, 200);
gridLeftCfg = '3%';
gridRightCfg = '4%';
dualGridTop = legendTop + legendH + (needNameTopGap ? 20 : 6);
} else {
legendCfg = showLegend ? {
show: true,
top: title ? 40 : 10,
top: title ? 38 : 8,
textStyle: { color: textColor, fontSize: 12 },
itemGap: 20,
icon: 'roundRect'
itemGap: 20
} : { show: false };
gridLeftCfg = '3%';
gridRightCfg = '4%';
if (dualYAxis) dualGridTop = title ? 42 : 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 +1638,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,
@@ -1435,7 +1657,12 @@ function buildCombineChartOption(dataText, cfg) {
{
type: 'value',
name: leftAxisName || '左轴',
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] },
min: axisNum(leftAxisMin),
max: axisNum(leftAxisMax),
nameLocation: leftAxisNameLoc === 'top' ? 'end' : 'middle',
nameRotate: leftAxisNameLoc === 'top' ? 0 : 90,
nameGap: leftAxisNameLoc === 'top' ? 12 : 30,
nameTextStyle: { color: textColor, fontSize: 11 },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
@@ -1445,7 +1672,12 @@ function buildCombineChartOption(dataText, cfg) {
type: 'value',
name: rightAxisName || '右轴',
position: 'right',
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 4, 0, 0] },
min: axisNum(rightAxisMin),
max: axisNum(rightAxisMax),
nameLocation: rightAxisNameLoc === 'top' ? 'end' : 'middle',
nameRotate: rightAxisNameLoc === 'top' ? 0 : 90,
nameGap: rightAxisNameLoc === 'top' ? 12 : 30,
nameTextStyle: { color: textColor, fontSize: 11 },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
@@ -1453,6 +1685,8 @@ function buildCombineChartOption(dataText, cfg) {
}
] : {
type: 'value',
min: axisNum(yAxisMin),
max: axisNum(yAxisMax),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
@@ -1475,7 +1709,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: '', leftAxisNameLoc: 'vertical', rightAxisNameLoc: 'vertical', labelColorMode: 'auto', labelColor: '#333333', yAxisMin: '', yAxisMax: '', leftAxisMin: '', leftAxisMax: '', rightAxisMin: '', rightAxisMax: '', seriesConfig: [], xAxisName: '', xAxisNameLoc: 'middle', xLabelRotate: 0 };
}
function initCombineCharts() {
@@ -1530,6 +1764,35 @@ function renderCombineCharts() {
<label><input type="checkbox" ${c.rowsAsSeries ? 'checked' : ''} onchange="updateCombineChart(${i},'rowsAsSeries',this.checked)"> 行=系列</label>
</div>
</div>
<div class="config-group">
<label>数据标签颜色(默认同系列颜色)</label>
<div class="res-input-row">
<select onchange="updateCombineChart(${i},'labelColorMode',this.value)" style="flex:1">
<option value="auto" ${(c.labelColorMode || 'auto') === 'auto' ? 'selected' : ''}>自动(同系列颜色)</option>
<option value="custom" ${c.labelColorMode === 'custom' ? 'selected' : ''}>自定义…</option>
</select>
<input type="color" value="${c.labelColor || '#333333'}" style="width:44px;height:32px" onchange="updateCombineChart(${i},'labelColor',this.value)">
</div>
</div>
<div class="config-group">
<label>Y轴范围(留空=自动)</label>
${c.dualYAxis ? `
<div class="res-input-row">
<input type="number" value="${escHtml(c.leftAxisMin)}" placeholder="左轴最小" oninput="updateCombineChart(${i},'leftAxisMin',this.value)">
<span class="res-times">~</span>
<input type="number" value="${escHtml(c.leftAxisMax)}" placeholder="左轴最大" oninput="updateCombineChart(${i},'leftAxisMax',this.value)">
</div>
<div class="res-input-row" style="margin-top:6px">
<input type="number" value="${escHtml(c.rightAxisMin)}" placeholder="右轴最小" oninput="updateCombineChart(${i},'rightAxisMin',this.value)">
<span class="res-times">~</span>
<input type="number" value="${escHtml(c.rightAxisMax)}" placeholder="右轴最大" oninput="updateCombineChart(${i},'rightAxisMax',this.value)">
</div>` : `
<div class="res-input-row">
<input type="number" value="${escHtml(c.yAxisMin)}" placeholder="最小值" oninput="updateCombineChart(${i},'yAxisMin',this.value)">
<span class="res-times">~</span>
<input type="number" value="${escHtml(c.yAxisMax)}" placeholder="最大值" oninput="updateCombineChart(${i},'yAxisMax',this.value)">
</div>`}
</div>
<div class="config-group">
<label><input type="checkbox" ${c.dualYAxis ? 'checked' : ''} onchange="updateCombineChart(${i},'dualYAxis',this.checked)"> 双Y轴(不同左右量度)</label>
</div>
@@ -1541,13 +1804,33 @@ function renderCombineCharts() {
<span class="res-times">/</span>
<input type="text" value="${escHtml(c.rightAxisName)}" placeholder="右轴,如:增长率(%)" oninput="updateCombineChart(${i},'rightAxisName',this.value)">
</div>
<div class="res-input-row" style="margin-top:8px">
<select onchange="updateCombineChart(${i},'leftAxisNameLoc',this.value)" title="左轴标题显示方式">
<option value="vertical" ${(c.leftAxisNameLoc || 'vertical') === 'vertical' ? 'selected' : ''}>左轴标题 · 竖直</option>
<option value="top" ${c.leftAxisNameLoc === 'top' ? 'selected' : ''}>左轴标题 · 轴上部</option>
</select>
<span class="res-times">/</span>
<select onchange="updateCombineChart(${i},'rightAxisNameLoc',this.value)" title="右轴标题显示方式">
<option value="vertical" ${(c.rightAxisNameLoc || 'vertical') === 'vertical' ? 'selected' : ''}>右轴标题 · 竖直</option>
<option value="top" ${c.rightAxisNameLoc === 'top' ? 'selected' : ''}>右轴标题 · 轴上部</option>
</select>
</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>
@@ -1577,6 +1860,11 @@ function updateCombineChart(i, field, value) {
const block = document.getElementById('combineSeriesCfg' + i);
if (block) block.innerHTML = renderCombineSeriesConfigHTML(i);
}
if (field === 'type') {
// 样式选项随柱状/折线联动
const block = document.getElementById('combineSeriesCfg' + i);
if (block) block.innerHTML = renderCombineSeriesConfigHTML(i);
}
generateCombine();
}
@@ -1591,7 +1879,7 @@ function syncCombineSeriesConfig(i) {
old.forEach(o => { if (o && o.name) oldMap[o.name] = o; });
c.seriesConfig = p.seriesNames.map(n => {
const prev = oldMap[n] || {};
return { name: n, type: prev.type || 'auto', axis: prev.axis !== undefined ? prev.axis : 0 };
return { name: n, type: prev.type || 'auto', axis: prev.axis !== undefined ? prev.axis : 0, style: prev.style || 'solid' };
});
}
@@ -1603,7 +1891,11 @@ function renderCombineSeriesConfigHTML(i) {
if (!p || !p.seriesNames.length) return '<p class="hint-text">输入数据后自动显示各系列的图表类型/坐标轴</p>';
const dual = !!c.dualYAxis;
return p.seriesNames.map((name, idx) => {
const sc = (c.seriesConfig && c.seriesConfig[idx]) || { name, type: 'auto', axis: 0 };
const sc = (c.seriesConfig && c.seriesConfig[idx]) || { name, type: 'auto', axis: 0, style: 'solid' };
const effType = (sc.type && sc.type !== 'auto') ? sc.type : 'bar';
const styleOptions = effType === 'line'
? [['solid', '实线'], ['dashed', '虚线'], ['dotted', '点线']]
: [['solid', '实体'], ['hollow', '空心'], ['shadow', '阴影']];
return `<div class="combine-series-row">
<span class="combine-series-name" title="${escHtml(name)}">${escHtml(name)}</span>
<select onchange="updateCombineSeriesCfg(${i}, ${idx}, 'type', this.value)" title="图表类型">
@@ -1611,6 +1903,9 @@ function renderCombineSeriesConfigHTML(i) {
<option value="bar" ${sc.type === 'bar' ? 'selected' : ''}>柱状</option>
<option value="line" ${sc.type === 'line' ? 'selected' : ''}>折线</option>
</select>
<select onchange="updateCombineSeriesCfg(${i}, ${idx}, 'style', this.value)" title="系列样式">
${styleOptions.map(([v, l]) => `<option value="${v}" ${(sc.style || 'solid') === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
${dual ? `<select onchange="updateCombineSeriesCfg(${i}, ${idx}, 'axis', this.value)" title="坐标轴">
<option value="0" ${String(sc.axis) === '0' ? 'selected' : ''}>左轴</option>
<option value="1" ${String(sc.axis) === '1' ? 'selected' : ''}>右轴</option>
@@ -1627,6 +1922,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 +2019,13 @@ 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
leftAxisNameLoc: c.leftAxisNameLoc || 'vertical', rightAxisNameLoc: c.rightAxisNameLoc || 'vertical',
labelColorMode: c.labelColorMode || 'auto', labelColor: c.labelColor || '#333333',
yAxisMin: c.yAxisMin !== undefined ? c.yAxisMin : '', yAxisMax: c.yAxisMax !== undefined ? c.yAxisMax : '',
leftAxisMin: c.leftAxisMin !== undefined ? c.leftAxisMin : '', leftAxisMax: c.leftAxisMax !== undefined ? c.leftAxisMax : '',
rightAxisMin: c.rightAxisMin !== undefined ? c.rightAxisMin : '', rightAxisMax: c.rightAxisMax !== undefined ? c.rightAxisMax : '',
xAxisName: c.xAxisName || '', xAxisNameLoc: c.xAxisNameLoc || 'middle', xLabelRotate: parseInt(c.xLabelRotate) || 0,
seriesConfig: c.seriesConfig, width: w
});
if (!option) { reject(new Error('数据格式错误')); return; }
const holder = document.createElement('div');
@@ -1854,8 +2193,19 @@ function collectChartConfig() {
dualAxis: document.getElementById('dualAxis').checked,
leftAxisName: document.getElementById('leftAxisName').value,
rightAxisName: document.getElementById('rightAxisName').value,
leftAxisNameLoc: (document.getElementById('leftAxisNameLoc') || {}).value || 'vertical',
rightAxisNameLoc: (document.getElementById('rightAxisNameLoc') || {}).value || 'vertical',
labelColorMode: (document.getElementById('labelColorMode') || {}).value || 'auto',
labelColor: (document.getElementById('labelColor') || {}).value || '#333333',
yAxisMin: (document.getElementById('yAxisMin') || {}).value || '',
yAxisMax: (document.getElementById('yAxisMax') || {}).value || '',
leftAxisMin: (document.getElementById('leftAxisMin') || {}).value || '',
leftAxisMax: (document.getElementById('leftAxisMax') || {}).value || '',
rightAxisMin: (document.getElementById('rightAxisMin') || {}).value || '',
rightAxisMax: (document.getElementById('rightAxisMax') || {}).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,
@@ -1864,7 +2214,8 @@ function collectChartConfig() {
seriesColors: [...seriesColors],
seriesOrder: [...seriesOrder],
seriesAxis: [...seriesAxis],
seriesTypeOverrides: window.seriesTypeOverrides || {}
seriesTypeOverrides: window.seriesTypeOverrides || {},
seriesStyleOverrides: window.seriesStyleOverrides || {}
};
}
@@ -2062,8 +2413,36 @@ function applyFavoriteConfig(fav) {
document.getElementById('dualAxis').checked = !!cfg.dualAxis;
document.getElementById('leftAxisName').value = cfg.leftAxisName || '';
document.getElementById('rightAxisName').value = cfg.rightAxisName || '';
const lNameLoc = document.getElementById('leftAxisNameLoc');
const rNameLoc = document.getElementById('rightAxisNameLoc');
if (lNameLoc) lNameLoc.value = cfg.leftAxisNameLoc || 'vertical';
if (rNameLoc) rNameLoc.value = cfg.rightAxisNameLoc || 'vertical';
const lcMode = document.getElementById('labelColorMode');
const lcColor = document.getElementById('labelColor');
if (lcMode) lcMode.value = cfg.labelColorMode || 'auto';
if (lcColor) lcColor.value = cfg.labelColor || '#333333';
if (lcColor) lcColor.style.display = (cfg.labelColorMode || 'auto') === 'custom' ? 'block' : 'none';
document.getElementById('yAxisMin').value = cfg.yAxisMin || '';
document.getElementById('yAxisMax').value = cfg.yAxisMax || '';
document.getElementById('leftAxisMin').value = cfg.leftAxisMin || '';
document.getElementById('leftAxisMax').value = cfg.leftAxisMax || '';
document.getElementById('rightAxisMin').value = cfg.rightAxisMin || '';
document.getElementById('rightAxisMax').value = cfg.rightAxisMax || '';
document.getElementById('xAxisName').value = cfg.xAxisName || '';
document.getElementById('xLabelRotate').value = cfg.xLabelRotate || 0;
document.getElementById('xAxisNameLoc').value = cfg.xAxisNameLoc || 'middle';
xAxisNameTouched = true; // 收藏恢复时尊重已存轴名,不再自动覆盖
// 刻度方向:命中 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 || '';
@@ -2077,6 +2456,7 @@ function applyFavoriteConfig(fav) {
if (Array.isArray(cfg.seriesColors)) seriesColors = [...cfg.seriesColors];
if (Array.isArray(cfg.seriesAxis)) seriesAxis = [...cfg.seriesAxis];
window.seriesTypeOverrides = cfg.seriesTypeOverrides || {};
window.seriesStyleOverrides = cfg.seriesStyleOverrides || {};
renderSeriesConfig();
updateChart();
onDualAxisChange();
+77 -8
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="onXAxisNameInput()">
</div>
<div class="config-group">
<label>轴名位置</label>
<select id="xAxisNameLoc" onchange="updateChart()">
<option value="middle" selected>横坐标轴下方居中</option>
<option value="end">横坐标右侧末端</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">
@@ -131,6 +155,40 @@ C, 20, 30, 40</pre>
</div>
</div>
<div class="config-group" id="labelColorGroup" style="display:none;">
<label>数据标签颜色(默认同系列颜色)</label>
<div class="res-input-row">
<select id="labelColorMode" onchange="onLabelColorModeChange()" style="flex:1">
<option value="auto" selected>自动(同系列颜色)</option>
<option value="custom">自定义…</option>
</select>
<input type="color" id="labelColor" value="#333333" style="width:44px;height:32px;display:none" onchange="updateChart()">
</div>
</div>
<div class="config-group">
<label>Y轴范围(留空=自动)</label>
<div id="singleAxisRange">
<div class="res-input-row">
<input type="number" id="yAxisMin" placeholder="最小值" oninput="updateChart()">
<span class="res-times">~</span>
<input type="number" id="yAxisMax" placeholder="最大值" oninput="updateChart()">
</div>
</div>
<div id="dualAxisRange" style="display:none;">
<div class="res-input-row">
<input type="number" id="leftAxisMin" placeholder="左轴最小" oninput="updateChart()">
<span class="res-times">~</span>
<input type="number" id="leftAxisMax" placeholder="左轴最大" oninput="updateChart()">
</div>
<div class="res-input-row" style="margin-top:8px">
<input type="number" id="rightAxisMin" placeholder="右轴最小" oninput="updateChart()">
<span class="res-times">~</span>
<input type="number" id="rightAxisMax" placeholder="右轴最大" oninput="updateChart()">
</div>
</div>
</div>
<div class="config-group" id="rightAxisGroup" style="display:none;">
<label>左右轴名称(量度可不同)</label>
<div class="res-input-row">
@@ -138,7 +196,18 @@ C, 20, 30, 40</pre>
<span class="res-times">/</span>
<input type="text" id="rightAxisName" placeholder="右轴,如:增长率(%)" oninput="updateChart()">
</div>
<p class="hint-text">在下方「系列配置」中可指定每个系列用左轴/右轴及图表类型(柱状/折线)</p>
<div class="res-input-row" style="margin-top:8px">
<select id="leftAxisNameLoc" onchange="updateChart()" title="左轴标题显示方式">
<option value="vertical" selected>左轴标题 · 竖直显示</option>
<option value="top">左轴标题 · 轴上部</option>
</select>
<span class="res-times">/</span>
<select id="rightAxisNameLoc" onchange="updateChart()" title="右轴标题显示方式">
<option value="vertical" selected>右轴标题 · 竖直显示</option>
<option value="top">右轴标题 · 轴上部</option>
</select>
</div>
<p class="hint-text">在下方「系列配置」中可指定每个系列用左轴/右轴、图表类型(柱状/折线)及样式(实体/空心/阴影、实线/虚线/点线)</p>
</div>
</div>
+147 -14
View File
@@ -124,6 +124,41 @@ function adjustColor(hex, amount) {
}
// ===== 构建 ECharts option =====
function hexToRgba(hex, alpha) {
hex = String(hex || '#888').replace('#', '');
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
const num = parseInt(hex, 16);
return `rgba(${(num >> 16) & 255},${(num >> 8) & 255},${num & 255},${alpha})`;
}
// 系列细分样式:柱状(实体/空心/阴影),折线(实线/虚线/点线)
function applySeriesStyle(seriesItem, type, color, style) {
if (!style || style === 'solid') return;
if (type === 'bar') {
if (style === 'hollow') {
const fill = (typeof color === 'string' && color.charAt(0) === '#') ? hexToRgba(color, 0.12) : 'rgba(120,120,120,0.12)';
seriesItem.itemStyle.color = fill;
seriesItem.itemStyle.borderColor = (typeof color === 'string' && color.charAt(0) === '#') ? color : '#888';
seriesItem.itemStyle.borderWidth = 2;
} else if (style === 'shadow') {
seriesItem.itemStyle.shadowBlur = 14;
seriesItem.itemStyle.shadowColor = (typeof color === 'string' && color.charAt(0) === '#') ? hexToRgba(color, 0.55) : 'rgba(0,0,0,0.35)';
seriesItem.itemStyle.shadowOffsetY = 4;
}
} else if (type === 'line') {
if (!seriesItem.lineStyle) seriesItem.lineStyle = { width: 3 };
if (style === 'dashed') seriesItem.lineStyle.type = 'dashed';
else if (style === 'dotted') seriesItem.lineStyle.type = 'dotted';
}
}
// Y轴 min/max:空值/非法返回 undefinedECharts 自动)
function axisNum(v) {
if (v === '' || v === undefined || v === null) return undefined;
const n = parseFloat(v);
return isNaN(n) ? undefined : n;
}
function buildChartOption(params) {
const {
data,
@@ -146,7 +181,19 @@ function buildChartOption(params) {
leftAxisName = '',
rightAxisName = '',
seriesTypes = null,
seriesAxis = null
seriesAxis = null,
seriesStyles = null,
leftAxisNameLoc = 'vertical',
rightAxisNameLoc = 'vertical',
labelColorMode = 'auto',
labelColor = '#333333',
yAxisMin = '',
yAxisMax = '',
leftAxisMin = '',
leftAxisMax = '',
rightAxisMin = '',
rightAxisMax = '',
width = 800
} = params;
const parsedData = parseData(data, params.rowsAsSeries);
@@ -279,12 +326,17 @@ function buildChartOption(params) {
seriesItem.itemStyle.borderRadius = stackMode ? [0, 0, 0, 0] : [4, 4, 0, 0];
}
// 系列细分样式:柱状(实体/空心/阴影) / 折线(实线/虚线/点线)
const st = Array.isArray(seriesStyles) ? seriesStyles[idx] : null;
applySeriesStyle(seriesItem, type, color, st);
// 数据标签:颜色默认同系列颜色,可手动自定义
if (showLabel) {
seriesItem.label = {
show: true,
position: 'top',
fontSize: 11,
color: textColor,
color: labelColorMode === 'custom' ? labelColor : color,
formatter: (p) => {
if (p.value >= 10000) return (p.value / 10000).toFixed(1) + 'w';
if (p.value >= 1000) return (p.value / 1000).toFixed(1) + 'k';
@@ -344,6 +396,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 ? 42 : 8;
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;
const needNameTopGap = (leftAxisNameLoc === 'top' || rightAxisNameLoc === 'top');
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
},
{
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
}
];
gridTop = legendTop + legendH + (needNameTopGap ? 24 : 8);
} else {
legendOpt = showLegend ? {
show: true,
top: title ? 44 : 10,
textStyle: { color: textColor, fontSize: 12 },
itemGap: 20
} : { show: false };
gridTop = showLegend ? (title ? 90 : 60) : (title ? 60 : 30);
}
// 完整 option
const option = {
backgroundColor: bgColor,
@@ -358,18 +462,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: {
@@ -388,7 +486,12 @@ function buildChartOption(params) {
{
type: 'value',
name: leftAxisName || '左轴',
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] },
min: axisNum(leftAxisMin),
max: axisNum(leftAxisMax),
nameLocation: leftAxisNameLoc === 'top' ? 'end' : 'middle',
nameRotate: leftAxisNameLoc === 'top' ? 0 : 90,
nameGap: leftAxisNameLoc === 'top' ? 12 : 32,
nameTextStyle: { color: textColor, fontSize: 11 },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
@@ -404,7 +507,12 @@ function buildChartOption(params) {
type: 'value',
name: rightAxisName || '右轴',
position: 'right',
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 4, 0, 0] },
min: axisNum(rightAxisMin),
max: axisNum(rightAxisMax),
nameLocation: rightAxisNameLoc === 'top' ? 'end' : 'middle',
nameRotate: rightAxisNameLoc === 'top' ? 0 : 90,
nameGap: rightAxisNameLoc === 'top' ? 12 : 32,
nameTextStyle: { color: textColor, fontSize: 11 },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
@@ -412,6 +520,8 @@ function buildChartOption(params) {
}
] : {
type: 'value',
min: axisNum(yAxisMin),
max: axisNum(yAxisMax),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
@@ -513,6 +623,17 @@ app.get('/api/chart', (req, res) => {
rightAxisName: req.query.rightAxisName || '',
seriesTypes: req.query.seriesTypes ? req.query.seriesTypes.split(',') : null,
seriesAxis: req.query.seriesAxis ? req.query.seriesAxis.split(',').map(Number) : null,
seriesStyles: req.query.seriesStyles ? req.query.seriesStyles.split(',') : null,
leftAxisNameLoc: req.query.leftAxisNameLoc || 'vertical',
rightAxisNameLoc: req.query.rightAxisNameLoc || 'vertical',
labelColorMode: req.query.labelColorMode || 'auto',
labelColor: req.query.labelColor || '#333333',
yAxisMin: req.query.yAxisMin || '',
yAxisMax: req.query.yAxisMax || '',
leftAxisMin: req.query.leftAxisMin || '',
leftAxisMax: req.query.leftAxisMax || '',
rightAxisMin: req.query.rightAxisMin || '',
rightAxisMax: req.query.rightAxisMax || '',
rowsAsSeries: req.query.rowsAsSeries === 'true',
width: parseInt(req.query.width) || 800,
height: parseInt(req.query.height) || 500,
@@ -1030,7 +1151,7 @@ app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
service: 'data-chart-tool',
version: '1.16.0',
version: '1.20.0',
endpoints: {
'POST /api/chart': '生成图表图片(JSON body',
'GET /api/chart': '生成图表图片(URL 参数)',
@@ -1050,7 +1171,7 @@ app.get('/api/health', (req, res) => {
app.get('/api/docs', (req, res) => {
res.json({
name: '数据可视化图表生成器 API',
version: '1.16.0',
version: '1.20.0',
endpoints: [
{
method: 'POST',
@@ -1082,6 +1203,17 @@ app.get('/api/docs', (req, res) => {
rightAxisName: { type: 'string', default: '', description: '右轴名称' },
seriesTypes: { type: 'array', default: null, description: '每系列图表类型(如 ["bar","line"],按系列顺序对应,bar/line/auto' },
seriesAxis: { type: 'array', default: null, description: '每系列坐标轴(如 [0,1]0=左轴 1=右轴,配合 dualYAxis 使用;优先于 rightAxisSeries' },
seriesStyles: { type: 'array', default: null, description: '每系列样式(如 ["solid","dashed"],柱状支持 solid实体/hollow空心/shadow阴影,折线支持 solid实线/dashed虚线/dotted点线)' },
leftAxisNameLoc: { type: 'string', default: 'vertical', options: ['vertical', 'top'], description: '左轴标题显示方式:vertical=竖直显示(默认)top=轴上部横排' },
rightAxisNameLoc: { type: 'string', default: 'vertical', options: ['vertical', 'top'], description: '右轴标题显示方式:vertical=竖直显示(默认)top=轴上部横排' },
labelColorMode: { type: 'string', default: 'auto', options: ['auto', 'custom'], description: '数据标签颜色:auto=同系列颜色(默认)custom=手动指定 labelColor' },
labelColor: { type: 'string', default: '#333333', description: '数据标签自定义颜色(labelColorMode=custom 时生效)' },
yAxisMin: { type: 'string', default: '', description: '单Y轴最小值(留空=自动)' },
yAxisMax: { type: 'string', default: '', description: '单Y轴最大值(留空=自动)' },
leftAxisMin: { type: 'string', default: '', description: '双Y轴左轴最小值(留空=自动)' },
leftAxisMax: { type: 'string', default: '', description: '双Y轴左轴最大值(留空=自动)' },
rightAxisMin: { type: 'string', default: '', description: '双Y轴右轴最小值(留空=自动)' },
rightAxisMax: { type: 'string', default: '', description: '双Y轴右轴最大值(留空=自动)' },
rowsAsSeries: { type: 'boolean', default: false, description: '数据方向:false=列=系列(第一列是横坐标,每列一个系列);true=行=系列(第一行是横坐标,每行一个系列)' }
},
returns: 'image/png',
@@ -1282,6 +1414,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, {