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

|

|
||||||

|

|
||||||
|
|
||||||
## ✨ 功能特性
|
## ✨ 功能特性
|
||||||
|
|
||||||
@@ -13,8 +13,9 @@
|
|||||||
- **混合图** - 柱状图+折线图组合展示
|
- **混合图** - 柱状图+折线图组合展示
|
||||||
- **饼图** - 环形占比展示(第一列=名称,第一系列=数值)
|
- **饼图** - 环形占比展示(第一列=名称,第一系列=数值)
|
||||||
- **雷达图** - 多维度对比(第一列=维度,每个系列=一个多边形)
|
- **雷达图** - 多维度对比(第一列=维度,每个系列=一个多边形)
|
||||||
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线);开启双轴时**图例放在各自轴上方、各自往两边对齐**(左轴系列图例靠左、右轴系列图例靠右,附「左轴/右轴」分组标题),不再占用两侧空白区,绘图区占满整图宽度,看图一目了然
|
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线);开启双轴时**图例放在各自轴上方、各自往两边对齐**(左轴系列图例靠左、右轴系列图例靠右,附「左轴/右轴」分组标题),不再占用两侧空白区,绘图区占满整图宽度;**图例图标随系列类型自动变化**(柱状=方块、折线=线条);**左右轴标题显示方式可选**(竖直显示/轴上部横排)
|
||||||
- **X轴设置** - 可填写**横坐标轴名**,**轴名位置可选**(右侧末端 / 轴下方居中),选轴名时自动预留空间不再截字;刻度文字方向**默认水平(0°)**,支持**下拉档位(每10°)** + **手动输入任意度数(0-90°)**,解决长类别名重叠问题
|
- **系列样式细分** - 每个系列可单独设置图表类型与样式:**柱状**分实体/空心/阴影,**折线**分实线/虚线/点线,图例与样式联动
|
||||||
|
- **X轴设置** - 横坐标轴名**默认自动取数据首列字段名**,**默认放在横坐标轴下方居中**(也可选右侧末端);选轴名时自动预留空间不再截字;刻度文字方向**默认水平(0°)**,支持**下拉档位(每10°)** + **手动输入任意度数(0-90°)**,解决长类别名重叠问题
|
||||||
- **数据方向(行=系列)** - 支持"行=系列"布局:第一行是横坐标,每行一个系列,每行可独立设置图表类型(柱状/折线)与左右轴量度(如:营收行=柱状左轴,销量行=折线右轴)
|
- **数据方向(行=系列)** - 支持"行=系列"布局:第一行是横坐标,每行一个系列,每行可独立设置图表类型(柱状/折线)与左右轴量度(如:营收行=柱状左轴,销量行=折线右轴)
|
||||||
|
|
||||||
### 📋 表格功能
|
### 📋 表格功能
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ function parseData(rawText, rowsAsSeries) {
|
|||||||
return {
|
return {
|
||||||
categories,
|
categories,
|
||||||
seriesNames: names,
|
seriesNames: names,
|
||||||
seriesData
|
seriesData,
|
||||||
|
firstField: headers[0] || ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +143,8 @@ function parseData(rawText, rowsAsSeries) {
|
|||||||
return {
|
return {
|
||||||
categories,
|
categories,
|
||||||
seriesNames: headers.slice(1),
|
seriesNames: headers.slice(1),
|
||||||
seriesData
|
seriesData,
|
||||||
|
firstField: headers[0] || ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +159,9 @@ function generateChart() {
|
|||||||
parsedData = parseData(rawText, document.getElementById('dataOrientation').value === 'rows');
|
parsedData = parseData(rawText, document.getElementById('dataOrientation').value === 'rows');
|
||||||
if (!parsedData) return;
|
if (!parsedData) return;
|
||||||
|
|
||||||
|
// X轴名默认取数据首列字段名(用户手动编辑过则保持不动)
|
||||||
|
setXAxisNameDefault(parsedData.firstField);
|
||||||
|
|
||||||
// 初始化系列顺序和颜色
|
// 初始化系列顺序和颜色
|
||||||
seriesOrder = parsedData.seriesNames.map((_, i) => i);
|
seriesOrder = parsedData.seriesNames.map((_, i) => i);
|
||||||
const palette = colorPalettes[document.getElementById('themeStyle').value] || colorPalettes.default;
|
const palette = colorPalettes[document.getElementById('themeStyle').value] || colorPalettes.default;
|
||||||
@@ -291,6 +296,8 @@ function updateChart() {
|
|||||||
const dualAxis = document.getElementById('dualAxis').checked;
|
const dualAxis = document.getElementById('dualAxis').checked;
|
||||||
const leftAxisName = document.getElementById('leftAxisName').value;
|
const leftAxisName = document.getElementById('leftAxisName').value;
|
||||||
const rightAxisName = document.getElementById('rightAxisName').value;
|
const rightAxisName = document.getElementById('rightAxisName').value;
|
||||||
|
const leftAxisNameLoc = (document.getElementById('leftAxisNameLoc') || {}).value || 'vertical';
|
||||||
|
const rightAxisNameLoc = (document.getElementById('rightAxisNameLoc') || {}).value || 'vertical';
|
||||||
const xAxisName = document.getElementById('xAxisName').value.trim();
|
const xAxisName = document.getElementById('xAxisName').value.trim();
|
||||||
const xAxisNameLoc = document.getElementById('xAxisNameLoc').value;
|
const xAxisNameLoc = document.getElementById('xAxisNameLoc').value;
|
||||||
const xLabelRotate = xLabelRotateValue();
|
const xLabelRotate = xLabelRotateValue();
|
||||||
@@ -372,6 +379,10 @@ function updateChart() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 系列细分样式:柱状(实体/空心/阴影) / 折线(实线/虚线/点线)
|
||||||
|
const styleOv = window.seriesStyleOverrides && window.seriesStyleOverrides[origIdx];
|
||||||
|
applySeriesStyle(seriesItem, type, color, styleOv);
|
||||||
|
|
||||||
// 数据标签
|
// 数据标签
|
||||||
if (showLabel) {
|
if (showLabel) {
|
||||||
seriesItem.label = {
|
seriesItem.label = {
|
||||||
@@ -464,6 +475,7 @@ function updateChart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 图表配置:双Y轴图例放到各自轴上方(左图例靠左、右图例靠右),不再占用两侧空白,grid 全宽可用
|
// 图表配置:双Y轴图例放到各自轴上方(左图例靠左、右图例靠右),不再占用两侧空白,grid 全宽可用
|
||||||
|
// 图例图标按系列类型自动(柱状=方块,折线=线条)
|
||||||
let legendConfig, gridLeftCfg, gridRightCfg, dualGridTop = null;
|
let legendConfig, gridLeftCfg, gridRightCfg, dualGridTop = null;
|
||||||
if (dualAxis && showLegend) {
|
if (dualAxis && showLegend) {
|
||||||
const left = [], right = [];
|
const left = [], right = [];
|
||||||
@@ -471,11 +483,13 @@ function updateChart() {
|
|||||||
const name = parsedData.seriesNames[origIdx];
|
const name = parsedData.seriesNames[origIdx];
|
||||||
(seriesAxis[origIdx] === 1 ? right : left).push(name);
|
(seriesAxis[origIdx] === 1 ? right : left).push(name);
|
||||||
});
|
});
|
||||||
const legendTop = title ? 50 : 16;
|
const legendTop = title ? 42 : 8;
|
||||||
const chartW = chartInstance ? chartInstance.getWidth() : 800;
|
const chartW = chartInstance ? chartInstance.getWidth() : 800;
|
||||||
const availW = Math.max(100, (chartW - 20) / 2);
|
const availW = Math.max(100, (chartW - 20) / 2);
|
||||||
const rows = Math.max(estimateLegendRows(left, 12, availW), estimateLegendRows(right, 12, availW));
|
const rows = Math.max(estimateLegendRows(left, 12, availW), estimateLegendRows(right, 12, availW));
|
||||||
const legendH = rows * 22 + 6; // 每行约 22px
|
const legendH = rows * 22 + 6; // 每行约 22px
|
||||||
|
// 轴名在顶部时需额外留空间,避免被图例遮挡;竖直显示时不占顶部空间
|
||||||
|
const needNameTopGap = (leftAxisNameLoc === 'top' || rightAxisNameLoc === 'top');
|
||||||
legendConfig = [
|
legendConfig = [
|
||||||
{
|
{
|
||||||
show: left.length > 0,
|
show: left.length > 0,
|
||||||
@@ -484,7 +498,7 @@ function updateChart() {
|
|||||||
width: '49%',
|
width: '49%',
|
||||||
data: left,
|
data: left,
|
||||||
title: left.length > 1 ? { text: '左轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined,
|
title: left.length > 1 ? { text: '左轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined,
|
||||||
textStyle: { color: textColor, fontSize: 12 }, itemGap: 14, icon: 'roundRect'
|
textStyle: { color: textColor, fontSize: 12 }, itemGap: 14
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
show: right.length > 0,
|
show: right.length > 0,
|
||||||
@@ -493,23 +507,22 @@ function updateChart() {
|
|||||||
width: '49%',
|
width: '49%',
|
||||||
data: right,
|
data: right,
|
||||||
title: right.length > 1 ? { text: '右轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined,
|
title: right.length > 1 ? { text: '右轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined,
|
||||||
textStyle: { color: textColor, fontSize: 12 }, itemGap: 14, icon: 'roundRect'
|
textStyle: { color: textColor, fontSize: 12 }, itemGap: 14
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
gridLeftCfg = '3%';
|
gridLeftCfg = '3%';
|
||||||
gridRightCfg = '4%';
|
gridRightCfg = '4%';
|
||||||
dualGridTop = legendTop + legendH + 8;
|
dualGridTop = legendTop + legendH + (needNameTopGap ? 24 : 8);
|
||||||
} else {
|
} else {
|
||||||
legendConfig = showLegend ? {
|
legendConfig = showLegend ? {
|
||||||
show: true,
|
show: true,
|
||||||
top: title ? 50 : 15,
|
top: title ? 44 : 10,
|
||||||
textStyle: { color: textColor, fontSize: 12 },
|
textStyle: { color: textColor, fontSize: 12 },
|
||||||
itemGap: 20,
|
itemGap: 20
|
||||||
icon: 'roundRect'
|
|
||||||
} : { show: false };
|
} : { show: false };
|
||||||
gridLeftCfg = '3%';
|
gridLeftCfg = '3%';
|
||||||
gridRightCfg = '4%';
|
gridRightCfg = '4%';
|
||||||
if (dualAxis) dualGridTop = title ? 52 : 24;
|
if (dualAxis) dualGridTop = title ? 46 : 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
// X轴名占位:右侧末端需加右 padding,轴下方居中需加下 padding(避免文字被截断)
|
// X轴名占位:右侧末端需加右 padding,轴下方居中需加下 padding(避免文字被截断)
|
||||||
@@ -586,7 +599,10 @@ function updateChart() {
|
|||||||
{
|
{
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: leftAxisName || '左轴',
|
name: leftAxisName || '左轴',
|
||||||
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] },
|
nameLocation: leftAxisNameLoc === 'top' ? 'end' : 'middle',
|
||||||
|
nameRotate: leftAxisNameLoc === 'top' ? 0 : 90,
|
||||||
|
nameGap: leftAxisNameLoc === 'top' ? 12 : 32,
|
||||||
|
nameTextStyle: { color: textColor, fontSize: 11 },
|
||||||
axisLine: { show: false },
|
axisLine: { show: false },
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
axisLabel: { color: textColor, fontSize: 11 },
|
axisLabel: { color: textColor, fontSize: 11 },
|
||||||
@@ -599,7 +615,10 @@ function updateChart() {
|
|||||||
type: 'value',
|
type: 'value',
|
||||||
name: rightAxisName || '右轴',
|
name: rightAxisName || '右轴',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 4, 0, 0] },
|
nameLocation: rightAxisNameLoc === 'top' ? 'end' : 'middle',
|
||||||
|
nameRotate: rightAxisNameLoc === 'top' ? 0 : 90,
|
||||||
|
nameGap: rightAxisNameLoc === 'top' ? 12 : 32,
|
||||||
|
nameTextStyle: { color: textColor, fontSize: 11 },
|
||||||
axisLine: { show: false },
|
axisLine: { show: false },
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
axisLabel: { color: textColor, fontSize: 11 },
|
axisLabel: { color: textColor, fontSize: 11 },
|
||||||
@@ -700,6 +719,19 @@ function renderSeriesConfig() {
|
|||||||
</select>
|
</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');
|
const item = document.createElement('div');
|
||||||
item.className = 'series-item';
|
item.className = 'series-item';
|
||||||
item.draggable = true;
|
item.draggable = true;
|
||||||
@@ -714,6 +746,7 @@ function renderSeriesConfig() {
|
|||||||
<option value="bar">柱状</option>
|
<option value="bar">柱状</option>
|
||||||
<option value="line">折线</option>
|
<option value="line">折线</option>
|
||||||
</select>
|
</select>
|
||||||
|
${styleSelect}
|
||||||
${axisSelect}
|
${axisSelect}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -797,6 +830,13 @@ function updateSeriesType(origIdx, type) {
|
|||||||
// 存储自定义类型覆盖
|
// 存储自定义类型覆盖
|
||||||
if (!window.seriesTypeOverrides) window.seriesTypeOverrides = {};
|
if (!window.seriesTypeOverrides) window.seriesTypeOverrides = {};
|
||||||
window.seriesTypeOverrides[origIdx] = type;
|
window.seriesTypeOverrides[origIdx] = type;
|
||||||
|
renderSeriesConfig(); // 样式选项随柱状/折线联动
|
||||||
|
updateChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSeriesStyle(origIdx, style) {
|
||||||
|
if (!window.seriesStyleOverrides) window.seriesStyleOverrides = {};
|
||||||
|
window.seriesStyleOverrides[origIdx] = style;
|
||||||
updateChart();
|
updateChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -871,6 +911,49 @@ function estimateLegendRows(names, fontSize, availWidth) {
|
|||||||
return rows;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function adjustColor(hex, amount) {
|
function adjustColor(hex, amount) {
|
||||||
hex = hex.replace('#', '');
|
hex = hex.replace('#', '');
|
||||||
const num = parseInt(hex, 16);
|
const num = parseInt(hex, 16);
|
||||||
@@ -1306,7 +1389,8 @@ function buildCombineChartOption(dataText, cfg) {
|
|||||||
showLegend = true, showGrid = true, showLabel = false,
|
showLegend = true, showGrid = true, showLabel = false,
|
||||||
stackMode = false, smoothLine = true,
|
stackMode = false, smoothLine = true,
|
||||||
dualYAxis = false, leftAxisName = '', rightAxisName = '', seriesConfig = null,
|
dualYAxis = false, leftAxisName = '', rightAxisName = '', seriesConfig = null,
|
||||||
xAxisName = '', xAxisNameLoc = 'end', xLabelRotate = 0, width = 600
|
leftAxisNameLoc = 'vertical', rightAxisNameLoc = 'vertical',
|
||||||
|
xAxisName = '', xAxisNameLoc = 'middle', xLabelRotate = 0, width = 600
|
||||||
} = cfg;
|
} = cfg;
|
||||||
|
|
||||||
const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff';
|
const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff';
|
||||||
@@ -1413,6 +1497,9 @@ function buildCombineChartOption(dataText, cfg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 系列细分样式:柱状(实体/空心/阴影) / 折线(实线/虚线/点线)
|
||||||
|
applySeriesStyle(s, type, color, sc.style);
|
||||||
|
|
||||||
if (showLabel) {
|
if (showLabel) {
|
||||||
s.label = {
|
s.label = {
|
||||||
show: true,
|
show: true,
|
||||||
@@ -1430,7 +1517,7 @@ function buildCombineChartOption(dataText, cfg) {
|
|||||||
return s;
|
return s;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 双Y轴图例放到各自轴上方(左靠左、右靠右),不再占用两侧空白
|
// 双Y轴图例放到各自轴上方(左靠左、右靠右),不再占用两侧空白;图标按系列类型自动
|
||||||
let legendCfg, gridLeftCfg, gridRightCfg, dualGridTop = null;
|
let legendCfg, gridLeftCfg, gridRightCfg, dualGridTop = null;
|
||||||
if (dualYAxis && showLegend) {
|
if (dualYAxis && showLegend) {
|
||||||
const left = [], right = [];
|
const left = [], right = [];
|
||||||
@@ -1438,10 +1525,11 @@ function buildCombineChartOption(dataText, cfg) {
|
|||||||
const sc = scMap[name] || {};
|
const sc = scMap[name] || {};
|
||||||
((sc.axis === 1 || sc.axis === '1') ? right : left).push(name);
|
((sc.axis === 1 || sc.axis === '1') ? right : left).push(name);
|
||||||
});
|
});
|
||||||
const legendTop = title ? 42 : 12;
|
const legendTop = title ? 38 : 8;
|
||||||
const availW = Math.max(80, (width - 16) / 2);
|
const availW = Math.max(80, (width - 16) / 2);
|
||||||
const rows = Math.max(estimateLegendRows(left, 11, availW), estimateLegendRows(right, 11, availW));
|
const rows = Math.max(estimateLegendRows(left, 11, availW), estimateLegendRows(right, 11, availW));
|
||||||
const legendH = rows * 20 + 4; // 每行约 20px
|
const legendH = rows * 20 + 4; // 每行约 20px
|
||||||
|
const needNameTopGap = (leftAxisNameLoc === 'top' || rightAxisNameLoc === 'top');
|
||||||
legendCfg = [
|
legendCfg = [
|
||||||
{
|
{
|
||||||
show: left.length > 0,
|
show: left.length > 0,
|
||||||
@@ -1450,7 +1538,7 @@ function buildCombineChartOption(dataText, cfg) {
|
|||||||
width: '49%',
|
width: '49%',
|
||||||
data: left,
|
data: left,
|
||||||
title: left.length > 1 ? { text: '左轴', textStyle: { color: textColor, fontSize: 10, fontWeight: 600 } } : undefined,
|
title: left.length > 1 ? { text: '左轴', textStyle: { color: textColor, fontSize: 10, fontWeight: 600 } } : undefined,
|
||||||
textStyle: { color: textColor, fontSize: 11 }, itemGap: 10, icon: 'roundRect'
|
textStyle: { color: textColor, fontSize: 11 }, itemGap: 10
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
show: right.length > 0,
|
show: right.length > 0,
|
||||||
@@ -1459,23 +1547,22 @@ function buildCombineChartOption(dataText, cfg) {
|
|||||||
width: '49%',
|
width: '49%',
|
||||||
data: right,
|
data: right,
|
||||||
title: right.length > 1 ? { text: '右轴', textStyle: { color: textColor, fontSize: 10, fontWeight: 600 } } : undefined,
|
title: right.length > 1 ? { text: '右轴', textStyle: { color: textColor, fontSize: 10, fontWeight: 600 } } : undefined,
|
||||||
textStyle: { color: textColor, fontSize: 11 }, itemGap: 10, icon: 'roundRect'
|
textStyle: { color: textColor, fontSize: 11 }, itemGap: 10
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
gridLeftCfg = '3%';
|
gridLeftCfg = '3%';
|
||||||
gridRightCfg = '4%';
|
gridRightCfg = '4%';
|
||||||
dualGridTop = legendTop + legendH + 6;
|
dualGridTop = legendTop + legendH + (needNameTopGap ? 20 : 6);
|
||||||
} else {
|
} else {
|
||||||
legendCfg = showLegend ? {
|
legendCfg = showLegend ? {
|
||||||
show: true,
|
show: true,
|
||||||
top: title ? 40 : 10,
|
top: title ? 38 : 8,
|
||||||
textStyle: { color: textColor, fontSize: 12 },
|
textStyle: { color: textColor, fontSize: 12 },
|
||||||
itemGap: 20,
|
itemGap: 20
|
||||||
icon: 'roundRect'
|
|
||||||
} : { show: false };
|
} : { show: false };
|
||||||
gridLeftCfg = '3%';
|
gridLeftCfg = '3%';
|
||||||
gridRightCfg = '4%';
|
gridRightCfg = '4%';
|
||||||
if (dualYAxis) dualGridTop = title ? 44 : 16;
|
if (dualYAxis) dualGridTop = title ? 42 : 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
// X轴名占位:右侧末端加右 padding,轴下方居中加下 padding(避免文字被截断)
|
// X轴名占位:右侧末端加右 padding,轴下方居中加下 padding(避免文字被截断)
|
||||||
@@ -1525,7 +1612,10 @@ function buildCombineChartOption(dataText, cfg) {
|
|||||||
{
|
{
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: leftAxisName || '左轴',
|
name: leftAxisName || '左轴',
|
||||||
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] },
|
nameLocation: leftAxisNameLoc === 'top' ? 'end' : 'middle',
|
||||||
|
nameRotate: leftAxisNameLoc === 'top' ? 0 : 90,
|
||||||
|
nameGap: leftAxisNameLoc === 'top' ? 12 : 30,
|
||||||
|
nameTextStyle: { color: textColor, fontSize: 11 },
|
||||||
axisLine: { show: false },
|
axisLine: { show: false },
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
axisLabel: { color: textColor, fontSize: 11 },
|
axisLabel: { color: textColor, fontSize: 11 },
|
||||||
@@ -1535,7 +1625,10 @@ function buildCombineChartOption(dataText, cfg) {
|
|||||||
type: 'value',
|
type: 'value',
|
||||||
name: rightAxisName || '右轴',
|
name: rightAxisName || '右轴',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 4, 0, 0] },
|
nameLocation: rightAxisNameLoc === 'top' ? 'end' : 'middle',
|
||||||
|
nameRotate: rightAxisNameLoc === 'top' ? 0 : 90,
|
||||||
|
nameGap: rightAxisNameLoc === 'top' ? 12 : 30,
|
||||||
|
nameTextStyle: { color: textColor, fontSize: 11 },
|
||||||
axisLine: { show: false },
|
axisLine: { show: false },
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
axisLabel: { color: textColor, fontSize: 11 },
|
axisLabel: { color: textColor, fontSize: 11 },
|
||||||
@@ -1565,7 +1658,7 @@ function measureCombineChart(dataText, rowsAsSeries) {
|
|||||||
let combineCharts = [];
|
let combineCharts = [];
|
||||||
|
|
||||||
function defaultCombineChart() {
|
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: '', xAxisNameLoc: 'end', 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', seriesConfig: [], xAxisName: '', xAxisNameLoc: 'middle', xLabelRotate: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function initCombineCharts() {
|
function initCombineCharts() {
|
||||||
@@ -1631,6 +1724,17 @@ function renderCombineCharts() {
|
|||||||
<span class="res-times">/</span>
|
<span class="res-times">/</span>
|
||||||
<input type="text" value="${escHtml(c.rightAxisName)}" placeholder="右轴,如:增长率(%)" oninput="updateCombineChart(${i},'rightAxisName',this.value)">
|
<input type="text" value="${escHtml(c.rightAxisName)}" placeholder="右轴,如:增长率(%)" oninput="updateCombineChart(${i},'rightAxisName',this.value)">
|
||||||
</div>
|
</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>` : ''}
|
||||||
<div class="config-group">
|
<div class="config-group">
|
||||||
<label>X轴设置</label>
|
<label>X轴设置</label>
|
||||||
@@ -1676,6 +1780,11 @@ function updateCombineChart(i, field, value) {
|
|||||||
const block = document.getElementById('combineSeriesCfg' + i);
|
const block = document.getElementById('combineSeriesCfg' + i);
|
||||||
if (block) block.innerHTML = renderCombineSeriesConfigHTML(i);
|
if (block) block.innerHTML = renderCombineSeriesConfigHTML(i);
|
||||||
}
|
}
|
||||||
|
if (field === 'type') {
|
||||||
|
// 样式选项随柱状/折线联动
|
||||||
|
const block = document.getElementById('combineSeriesCfg' + i);
|
||||||
|
if (block) block.innerHTML = renderCombineSeriesConfigHTML(i);
|
||||||
|
}
|
||||||
generateCombine();
|
generateCombine();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1690,7 +1799,7 @@ function syncCombineSeriesConfig(i) {
|
|||||||
old.forEach(o => { if (o && o.name) oldMap[o.name] = o; });
|
old.forEach(o => { if (o && o.name) oldMap[o.name] = o; });
|
||||||
c.seriesConfig = p.seriesNames.map(n => {
|
c.seriesConfig = p.seriesNames.map(n => {
|
||||||
const prev = oldMap[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' };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1702,7 +1811,11 @@ function renderCombineSeriesConfigHTML(i) {
|
|||||||
if (!p || !p.seriesNames.length) return '<p class="hint-text">输入数据后自动显示各系列的图表类型/坐标轴</p>';
|
if (!p || !p.seriesNames.length) return '<p class="hint-text">输入数据后自动显示各系列的图表类型/坐标轴</p>';
|
||||||
const dual = !!c.dualYAxis;
|
const dual = !!c.dualYAxis;
|
||||||
return p.seriesNames.map((name, idx) => {
|
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">
|
return `<div class="combine-series-row">
|
||||||
<span class="combine-series-name" title="${escHtml(name)}">${escHtml(name)}</span>
|
<span class="combine-series-name" title="${escHtml(name)}">${escHtml(name)}</span>
|
||||||
<select onchange="updateCombineSeriesCfg(${i}, ${idx}, 'type', this.value)" title="图表类型">
|
<select onchange="updateCombineSeriesCfg(${i}, ${idx}, 'type', this.value)" title="图表类型">
|
||||||
@@ -1710,6 +1823,9 @@ function renderCombineSeriesConfigHTML(i) {
|
|||||||
<option value="bar" ${sc.type === 'bar' ? 'selected' : ''}>柱状</option>
|
<option value="bar" ${sc.type === 'bar' ? 'selected' : ''}>柱状</option>
|
||||||
<option value="line" ${sc.type === 'line' ? 'selected' : ''}>折线</option>
|
<option value="line" ${sc.type === 'line' ? 'selected' : ''}>折线</option>
|
||||||
</select>
|
</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="坐标轴">
|
${dual ? `<select onchange="updateCombineSeriesCfg(${i}, ${idx}, 'axis', this.value)" title="坐标轴">
|
||||||
<option value="0" ${String(sc.axis) === '0' ? 'selected' : ''}>左轴</option>
|
<option value="0" ${String(sc.axis) === '0' ? 'selected' : ''}>左轴</option>
|
||||||
<option value="1" ${String(sc.axis) === '1' ? 'selected' : ''}>右轴</option>
|
<option value="1" ${String(sc.axis) === '1' ? 'selected' : ''}>右轴</option>
|
||||||
@@ -1823,7 +1939,8 @@ function generateCombine() {
|
|||||||
showLegend: sharedLegend ? false : c.legend, showGrid: c.grid, showLabel: c.label, stackMode: c.stack,
|
showLegend: sharedLegend ? false : c.legend, showGrid: c.grid, showLabel: c.label, stackMode: c.stack,
|
||||||
rowsAsSeries: !!c.rowsAsSeries,
|
rowsAsSeries: !!c.rowsAsSeries,
|
||||||
dualYAxis: !!c.dualYAxis, leftAxisName: c.leftAxisName || '', rightAxisName: c.rightAxisName || '',
|
dualYAxis: !!c.dualYAxis, leftAxisName: c.leftAxisName || '', rightAxisName: c.rightAxisName || '',
|
||||||
xAxisName: c.xAxisName || '', xAxisNameLoc: c.xAxisNameLoc || 'end', xLabelRotate: parseInt(c.xLabelRotate) || 0,
|
leftAxisNameLoc: c.leftAxisNameLoc || 'vertical', rightAxisNameLoc: c.rightAxisNameLoc || 'vertical',
|
||||||
|
xAxisName: c.xAxisName || '', xAxisNameLoc: c.xAxisNameLoc || 'middle', xLabelRotate: parseInt(c.xLabelRotate) || 0,
|
||||||
seriesConfig: c.seriesConfig, width: w
|
seriesConfig: c.seriesConfig, width: w
|
||||||
});
|
});
|
||||||
if (!option) { reject(new Error('数据格式错误')); return; }
|
if (!option) { reject(new Error('数据格式错误')); return; }
|
||||||
@@ -1992,6 +2109,8 @@ function collectChartConfig() {
|
|||||||
dualAxis: document.getElementById('dualAxis').checked,
|
dualAxis: document.getElementById('dualAxis').checked,
|
||||||
leftAxisName: document.getElementById('leftAxisName').value,
|
leftAxisName: document.getElementById('leftAxisName').value,
|
||||||
rightAxisName: document.getElementById('rightAxisName').value,
|
rightAxisName: document.getElementById('rightAxisName').value,
|
||||||
|
leftAxisNameLoc: (document.getElementById('leftAxisNameLoc') || {}).value || 'vertical',
|
||||||
|
rightAxisNameLoc: (document.getElementById('rightAxisNameLoc') || {}).value || 'vertical',
|
||||||
xAxisName: document.getElementById('xAxisName').value,
|
xAxisName: document.getElementById('xAxisName').value,
|
||||||
xAxisNameLoc: document.getElementById('xAxisNameLoc').value,
|
xAxisNameLoc: document.getElementById('xAxisNameLoc').value,
|
||||||
xLabelRotate: xLabelRotateValue(),
|
xLabelRotate: xLabelRotateValue(),
|
||||||
@@ -2003,7 +2122,8 @@ function collectChartConfig() {
|
|||||||
seriesColors: [...seriesColors],
|
seriesColors: [...seriesColors],
|
||||||
seriesOrder: [...seriesOrder],
|
seriesOrder: [...seriesOrder],
|
||||||
seriesAxis: [...seriesAxis],
|
seriesAxis: [...seriesAxis],
|
||||||
seriesTypeOverrides: window.seriesTypeOverrides || {}
|
seriesTypeOverrides: window.seriesTypeOverrides || {},
|
||||||
|
seriesStyleOverrides: window.seriesStyleOverrides || {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2201,8 +2321,13 @@ function applyFavoriteConfig(fav) {
|
|||||||
document.getElementById('dualAxis').checked = !!cfg.dualAxis;
|
document.getElementById('dualAxis').checked = !!cfg.dualAxis;
|
||||||
document.getElementById('leftAxisName').value = cfg.leftAxisName || '';
|
document.getElementById('leftAxisName').value = cfg.leftAxisName || '';
|
||||||
document.getElementById('rightAxisName').value = cfg.rightAxisName || '';
|
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';
|
||||||
document.getElementById('xAxisName').value = cfg.xAxisName || '';
|
document.getElementById('xAxisName').value = cfg.xAxisName || '';
|
||||||
document.getElementById('xAxisNameLoc').value = cfg.xAxisNameLoc || 'end';
|
document.getElementById('xAxisNameLoc').value = cfg.xAxisNameLoc || 'middle';
|
||||||
|
xAxisNameTouched = true; // 收藏恢复时尊重已存轴名,不再自动覆盖
|
||||||
// 刻度方向:命中 10° 档位用下拉,否则用自定义输入
|
// 刻度方向:命中 10° 档位用下拉,否则用自定义输入
|
||||||
const rotV = cfg.xLabelRotate || 0;
|
const rotV = cfg.xLabelRotate || 0;
|
||||||
const rotSel = document.getElementById('xLabelRotate');
|
const rotSel = document.getElementById('xLabelRotate');
|
||||||
@@ -2228,6 +2353,7 @@ function applyFavoriteConfig(fav) {
|
|||||||
if (Array.isArray(cfg.seriesColors)) seriesColors = [...cfg.seriesColors];
|
if (Array.isArray(cfg.seriesColors)) seriesColors = [...cfg.seriesColors];
|
||||||
if (Array.isArray(cfg.seriesAxis)) seriesAxis = [...cfg.seriesAxis];
|
if (Array.isArray(cfg.seriesAxis)) seriesAxis = [...cfg.seriesAxis];
|
||||||
window.seriesTypeOverrides = cfg.seriesTypeOverrides || {};
|
window.seriesTypeOverrides = cfg.seriesTypeOverrides || {};
|
||||||
|
window.seriesStyleOverrides = cfg.seriesStyleOverrides || {};
|
||||||
renderSeriesConfig();
|
renderSeriesConfig();
|
||||||
updateChart();
|
updateChart();
|
||||||
onDualAxisChange();
|
onDualAxisChange();
|
||||||
|
|||||||
+15
-4
@@ -83,14 +83,14 @@ C, 20, 30, 40</pre>
|
|||||||
<div class="config-group">
|
<div class="config-group">
|
||||||
<label>X轴设置</label>
|
<label>X轴设置</label>
|
||||||
<div class="config-group">
|
<div class="config-group">
|
||||||
<label>横坐标轴名(可留空)</label>
|
<label>横坐标轴名(留空时自动用数据首列字段名)</label>
|
||||||
<input type="text" id="xAxisName" placeholder="如:月份 / 年份" oninput="updateChart()">
|
<input type="text" id="xAxisName" placeholder="默认取数据首列字段名,如:月份" oninput="onXAxisNameInput()">
|
||||||
</div>
|
</div>
|
||||||
<div class="config-group">
|
<div class="config-group">
|
||||||
<label>轴名位置</label>
|
<label>轴名位置</label>
|
||||||
<select id="xAxisNameLoc" onchange="updateChart()">
|
<select id="xAxisNameLoc" onchange="updateChart()">
|
||||||
|
<option value="middle" selected>横坐标轴下方居中</option>
|
||||||
<option value="end">横坐标右侧末端</option>
|
<option value="end">横坐标右侧末端</option>
|
||||||
<option value="middle">横坐标轴下方居中</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-group">
|
<div class="config-group">
|
||||||
@@ -162,7 +162,18 @@ C, 20, 30, 40</pre>
|
|||||||
<span class="res-times">/</span>
|
<span class="res-times">/</span>
|
||||||
<input type="text" id="rightAxisName" placeholder="右轴,如:增长率(%)" oninput="updateChart()">
|
<input type="text" id="rightAxisName" placeholder="右轴,如:增长率(%)" oninput="updateChart()">
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,34 @@ function adjustColor(hex, amount) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== 构建 ECharts option =====
|
// ===== 构建 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildChartOption(params) {
|
function buildChartOption(params) {
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -147,6 +175,9 @@ function buildChartOption(params) {
|
|||||||
rightAxisName = '',
|
rightAxisName = '',
|
||||||
seriesTypes = null,
|
seriesTypes = null,
|
||||||
seriesAxis = null,
|
seriesAxis = null,
|
||||||
|
seriesStyles = null,
|
||||||
|
leftAxisNameLoc = 'vertical',
|
||||||
|
rightAxisNameLoc = 'vertical',
|
||||||
width = 800
|
width = 800
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
@@ -280,6 +311,10 @@ function buildChartOption(params) {
|
|||||||
seriesItem.itemStyle.borderRadius = stackMode ? [0, 0, 0, 0] : [4, 4, 0, 0];
|
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) {
|
if (showLabel) {
|
||||||
seriesItem.label = {
|
seriesItem.label = {
|
||||||
show: true,
|
show: true,
|
||||||
@@ -345,12 +380,12 @@ function buildChartOption(params) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 双Y轴:图例放到各自轴上方(左图例靠左、右图例靠右),不再占用两侧空白
|
// 双Y轴:图例放到各自轴上方(左图例靠左、右图例靠右),不再占用两侧空白;图标按系列类型自动
|
||||||
let legendOpt, gridTop;
|
let legendOpt, gridTop;
|
||||||
if (dualYAxis && showLegend) {
|
if (dualYAxis && showLegend) {
|
||||||
const left = [], right = [];
|
const left = [], right = [];
|
||||||
series.forEach(s => { if (s.yAxisIndex === 1) right.push(s.name); else left.push(s.name); });
|
series.forEach(s => { if (s.yAxisIndex === 1) right.push(s.name); else left.push(s.name); });
|
||||||
const legendTop = title ? 50 : 16;
|
const legendTop = title ? 42 : 8;
|
||||||
const availW = Math.max(100, (width - 20) / 2);
|
const availW = Math.max(100, (width - 20) / 2);
|
||||||
// 粗略估算文字宽度:中文≈字号宽,ASCII≈字号*0.6
|
// 粗略估算文字宽度:中文≈字号宽,ASCII≈字号*0.6
|
||||||
const textW = (n) => [...String(n)].reduce((w, ch) => w + (ch.charCodeAt(0) > 255 ? 12 : 7), 0);
|
const textW = (n) => [...String(n)].reduce((w, ch) => w + (ch.charCodeAt(0) > 255 ? 12 : 7), 0);
|
||||||
@@ -367,6 +402,7 @@ function buildChartOption(params) {
|
|||||||
};
|
};
|
||||||
const rows = Math.max(estRows(left), estRows(right));
|
const rows = Math.max(estRows(left), estRows(right));
|
||||||
const legendH = rows * 22 + 6;
|
const legendH = rows * 22 + 6;
|
||||||
|
const needNameTopGap = (leftAxisNameLoc === 'top' || rightAxisNameLoc === 'top');
|
||||||
legendOpt = [
|
legendOpt = [
|
||||||
{
|
{
|
||||||
show: left.length > 0,
|
show: left.length > 0,
|
||||||
@@ -374,7 +410,7 @@ function buildChartOption(params) {
|
|||||||
top: legendTop, width: '49%',
|
top: legendTop, width: '49%',
|
||||||
data: left,
|
data: left,
|
||||||
title: left.length > 1 ? { text: '左轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined,
|
title: left.length > 1 ? { text: '左轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined,
|
||||||
textStyle: { color: textColor, fontSize: 12 }, itemGap: 14, icon: 'roundRect'
|
textStyle: { color: textColor, fontSize: 12 }, itemGap: 14
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
show: right.length > 0,
|
show: right.length > 0,
|
||||||
@@ -382,17 +418,16 @@ function buildChartOption(params) {
|
|||||||
top: legendTop, width: '49%',
|
top: legendTop, width: '49%',
|
||||||
data: right,
|
data: right,
|
||||||
title: right.length > 1 ? { text: '右轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined,
|
title: right.length > 1 ? { text: '右轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined,
|
||||||
textStyle: { color: textColor, fontSize: 12 }, itemGap: 14, icon: 'roundRect'
|
textStyle: { color: textColor, fontSize: 12 }, itemGap: 14
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
gridTop = legendTop + legendH + 8;
|
gridTop = legendTop + legendH + (needNameTopGap ? 24 : 8);
|
||||||
} else {
|
} else {
|
||||||
legendOpt = showLegend ? {
|
legendOpt = showLegend ? {
|
||||||
show: true,
|
show: true,
|
||||||
top: title ? 50 : 15,
|
top: title ? 44 : 10,
|
||||||
textStyle: { color: textColor, fontSize: 12 },
|
textStyle: { color: textColor, fontSize: 12 },
|
||||||
itemGap: 20,
|
itemGap: 20
|
||||||
icon: 'roundRect'
|
|
||||||
} : { show: false };
|
} : { show: false };
|
||||||
gridTop = showLegend ? (title ? 90 : 60) : (title ? 60 : 30);
|
gridTop = showLegend ? (title ? 90 : 60) : (title ? 60 : 30);
|
||||||
}
|
}
|
||||||
@@ -435,7 +470,10 @@ function buildChartOption(params) {
|
|||||||
{
|
{
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: leftAxisName || '左轴',
|
name: leftAxisName || '左轴',
|
||||||
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] },
|
nameLocation: leftAxisNameLoc === 'top' ? 'end' : 'middle',
|
||||||
|
nameRotate: leftAxisNameLoc === 'top' ? 0 : 90,
|
||||||
|
nameGap: leftAxisNameLoc === 'top' ? 12 : 32,
|
||||||
|
nameTextStyle: { color: textColor, fontSize: 11 },
|
||||||
axisLine: { show: false },
|
axisLine: { show: false },
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
axisLabel: { color: textColor, fontSize: 11 },
|
axisLabel: { color: textColor, fontSize: 11 },
|
||||||
@@ -451,7 +489,10 @@ function buildChartOption(params) {
|
|||||||
type: 'value',
|
type: 'value',
|
||||||
name: rightAxisName || '右轴',
|
name: rightAxisName || '右轴',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 4, 0, 0] },
|
nameLocation: rightAxisNameLoc === 'top' ? 'end' : 'middle',
|
||||||
|
nameRotate: rightAxisNameLoc === 'top' ? 0 : 90,
|
||||||
|
nameGap: rightAxisNameLoc === 'top' ? 12 : 32,
|
||||||
|
nameTextStyle: { color: textColor, fontSize: 11 },
|
||||||
axisLine: { show: false },
|
axisLine: { show: false },
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
axisLabel: { color: textColor, fontSize: 11 },
|
axisLabel: { color: textColor, fontSize: 11 },
|
||||||
@@ -560,6 +601,9 @@ app.get('/api/chart', (req, res) => {
|
|||||||
rightAxisName: req.query.rightAxisName || '',
|
rightAxisName: req.query.rightAxisName || '',
|
||||||
seriesTypes: req.query.seriesTypes ? req.query.seriesTypes.split(',') : null,
|
seriesTypes: req.query.seriesTypes ? req.query.seriesTypes.split(',') : null,
|
||||||
seriesAxis: req.query.seriesAxis ? req.query.seriesAxis.split(',').map(Number) : 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',
|
||||||
rowsAsSeries: req.query.rowsAsSeries === 'true',
|
rowsAsSeries: req.query.rowsAsSeries === 'true',
|
||||||
width: parseInt(req.query.width) || 800,
|
width: parseInt(req.query.width) || 800,
|
||||||
height: parseInt(req.query.height) || 500,
|
height: parseInt(req.query.height) || 500,
|
||||||
@@ -1077,7 +1121,7 @@ app.get('/api/health', (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
service: 'data-chart-tool',
|
service: 'data-chart-tool',
|
||||||
version: '1.18.0',
|
version: '1.19.0',
|
||||||
endpoints: {
|
endpoints: {
|
||||||
'POST /api/chart': '生成图表图片(JSON body)',
|
'POST /api/chart': '生成图表图片(JSON body)',
|
||||||
'GET /api/chart': '生成图表图片(URL 参数)',
|
'GET /api/chart': '生成图表图片(URL 参数)',
|
||||||
@@ -1097,7 +1141,7 @@ app.get('/api/health', (req, res) => {
|
|||||||
app.get('/api/docs', (req, res) => {
|
app.get('/api/docs', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
name: '数据可视化图表生成器 API',
|
name: '数据可视化图表生成器 API',
|
||||||
version: '1.18.0',
|
version: '1.19.0',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1129,6 +1173,9 @@ app.get('/api/docs', (req, res) => {
|
|||||||
rightAxisName: { type: 'string', default: '', description: '右轴名称' },
|
rightAxisName: { type: 'string', default: '', description: '右轴名称' },
|
||||||
seriesTypes: { type: 'array', default: null, description: '每系列图表类型(如 ["bar","line"],按系列顺序对应,bar/line/auto)' },
|
seriesTypes: { type: 'array', default: null, description: '每系列图表类型(如 ["bar","line"],按系列顺序对应,bar/line/auto)' },
|
||||||
seriesAxis: { type: 'array', default: null, description: '每系列坐标轴(如 [0,1],0=左轴 1=右轴,配合 dualYAxis 使用;优先于 rightAxisSeries)' },
|
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=轴上部横排' },
|
||||||
rowsAsSeries: { type: 'boolean', default: false, description: '数据方向:false=列=系列(第一列是横坐标,每列一个系列);true=行=系列(第一行是横坐标,每行一个系列)' }
|
rowsAsSeries: { type: 'boolean', default: false, description: '数据方向:false=列=系列(第一列是横坐标,每列一个系列);true=行=系列(第一行是横坐标,每行一个系列)' }
|
||||||
},
|
},
|
||||||
returns: 'image/png',
|
returns: 'image/png',
|
||||||
|
|||||||
Reference in New Issue
Block a user