From 864c43ab99f33a0f5c8cf5879b1421a66f890240 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Sun, 23 Aug 2026 15:30:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=BE=E4=BE=8B=E4=B8=8A=E7=A7=BB?= =?UTF-8?q?=E4=B8=8D=E9=81=AE=E8=BD=B4=E5=90=8D+=E6=8C=89=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E6=98=BE=E7=A4=BA=E5=9B=BE=E6=A0=87+=E7=B3=BB?= =?UTF-8?q?=E5=88=97=E6=A0=B7=E5=BC=8F=E7=BB=86=E5=88=86+=E8=BD=B4?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E7=AB=96=E7=9B=B4/=E9=A1=B6=E9=83=A8+X?= =?UTF-8?q?=E8=BD=B4=E5=90=8D=E9=BB=98=E8=AE=A4=E5=8F=96=E9=A6=96=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=94=BE=E8=BD=B4=E4=B8=8B=20v1.19.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 双轴图例上移到更靠上位置,轴名在顶部模式时 grid 自动多让位,不再遮住左右轴名 - 图例图标按系列类型自动(柱状=方块、折线=线条),去掉固定 roundRect - 系列样式细分:柱状实体/空心/阴影,折线实线/虚线/点线(主图+多图合并+服务端API 三处统一) - 左右轴标题显示方式可选:竖直显示(默认)/轴上部横排,双轴图主图/合并/API 均支持 - X轴名默认自动取数据首列字段名,默认放横坐标轴下方居中;用户手动编辑后不再自动覆盖 - 收藏保存/恢复含系列样式与轴名显示方式 --- README.md | 7 +- app.js | 186 ++++++++++++++++++++++++++++++++++++++++++++--------- index.html | 19 ++++-- server.js | 71 ++++++++++++++++---- 4 files changed, 234 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index cb926a2..9f779f0 100644 --- a/README.md +++ b/README.md @@ -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.18.0-green.svg) +![Version](https://img.shields.io/badge/version-1.19.0-green.svg) ## ✨ 功能特性 @@ -13,8 +13,9 @@ - **混合图** - 柱状图+折线图组合展示 - **饼图** - 环形占比展示(第一列=名称,第一系列=数值) - **雷达图** - 多维度对比(第一列=维度,每个系列=一个多边形) -- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线);开启双轴时**图例放在各自轴上方、各自往两边对齐**(左轴系列图例靠左、右轴系列图例靠右,附「左轴/右轴」分组标题),不再占用两侧空白区,绘图区占满整图宽度,看图一目了然 -- **X轴设置** - 可填写**横坐标轴名**,**轴名位置可选**(右侧末端 / 轴下方居中),选轴名时自动预留空间不再截字;刻度文字方向**默认水平(0°)**,支持**下拉档位(每10°)** + **手动输入任意度数(0-90°)**,解决长类别名重叠问题 +- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线);开启双轴时**图例放在各自轴上方、各自往两边对齐**(左轴系列图例靠左、右轴系列图例靠右,附「左轴/右轴」分组标题),不再占用两侧空白区,绘图区占满整图宽度;**图例图标随系列类型自动变化**(柱状=方块、折线=线条);**左右轴标题显示方式可选**(竖直显示/轴上部横排) +- **系列样式细分** - 每个系列可单独设置图表类型与样式:**柱状**分实体/空心/阴影,**折线**分实线/虚线/点线,图例与样式联动 +- **X轴设置** - 横坐标轴名**默认自动取数据首列字段名**,**默认放在横坐标轴下方居中**(也可选右侧末端);选轴名时自动预留空间不再截字;刻度文字方向**默认水平(0°)**,支持**下拉档位(每10°)** + **手动输入任意度数(0-90°)**,解决长类别名重叠问题 - **数据方向(行=系列)** - 支持"行=系列"布局:第一行是横坐标,每行一个系列,每行可独立设置图表类型(柱状/折线)与左右轴量度(如:营收行=柱状左轴,销量行=折线右轴) ### 📋 表格功能 diff --git a/app.js b/app.js index 68220ba..867992a 100644 --- a/app.js +++ b/app.js @@ -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,6 +296,8 @@ function updateChart() { const dualAxis = document.getElementById('dualAxis').checked; const leftAxisName = document.getElementById('leftAxisName').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 xAxisNameLoc = document.getElementById('xAxisNameLoc').value; const xLabelRotate = xLabelRotateValue(); @@ -372,6 +379,10 @@ function updateChart() { } } + // 系列细分样式:柱状(实体/空心/阴影) / 折线(实线/虚线/点线) + const styleOv = window.seriesStyleOverrides && window.seriesStyleOverrides[origIdx]; + applySeriesStyle(seriesItem, type, color, styleOv); + // 数据标签 if (showLabel) { seriesItem.label = { @@ -464,6 +475,7 @@ function updateChart() { } // 图表配置:双Y轴图例放到各自轴上方(左图例靠左、右图例靠右),不再占用两侧空白,grid 全宽可用 + // 图例图标按系列类型自动(柱状=方块,折线=线条) let legendConfig, gridLeftCfg, gridRightCfg, dualGridTop = null; if (dualAxis && showLegend) { const left = [], right = []; @@ -471,11 +483,13 @@ function updateChart() { const name = parsedData.seriesNames[origIdx]; (seriesAxis[origIdx] === 1 ? right : left).push(name); }); - const legendTop = title ? 50 : 16; + 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, @@ -484,7 +498,7 @@ function updateChart() { width: '49%', data: left, title: left.length > 1 ? { text: '左轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined, - textStyle: { color: textColor, fontSize: 12 }, itemGap: 14, icon: 'roundRect' + textStyle: { color: textColor, fontSize: 12 }, itemGap: 14 }, { show: right.length > 0, @@ -493,23 +507,22 @@ function updateChart() { width: '49%', data: right, title: right.length > 1 ? { text: '右轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined, - textStyle: { color: textColor, fontSize: 12 }, itemGap: 14, icon: 'roundRect' + textStyle: { color: textColor, fontSize: 12 }, itemGap: 14 } ]; gridLeftCfg = '3%'; gridRightCfg = '4%'; - dualGridTop = legendTop + legendH + 8; + 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 ? 52 : 24; + if (dualAxis) dualGridTop = title ? 46 : 20; } // X轴名占位:右侧末端需加右 padding,轴下方居中需加下 padding(避免文字被截断) @@ -586,7 +599,10 @@ function updateChart() { { type: 'value', 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 }, axisTick: { show: false }, axisLabel: { color: textColor, fontSize: 11 }, @@ -599,7 +615,10 @@ function updateChart() { type: 'value', name: rightAxisName || '右轴', 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 }, axisTick: { show: false }, axisLabel: { color: textColor, fontSize: 11 }, @@ -700,6 +719,19 @@ function renderSeriesConfig() { ` : ''; + // 系列样式细分:柱状(实体/空心/阴影),折线(实线/虚线/点线),随类型联动 + 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 = ` + + `; + const item = document.createElement('div'); item.className = 'series-item'; item.draggable = true; @@ -714,6 +746,7 @@ function renderSeriesConfig() { + ${styleSelect} ${axisSelect} `; @@ -797,6 +830,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(); } @@ -871,6 +911,49 @@ function estimateLegendRows(names, fontSize, availWidth) { 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) { hex = hex.replace('#', ''); const num = parseInt(hex, 16); @@ -1306,7 +1389,8 @@ function buildCombineChartOption(dataText, cfg) { showLegend = true, showGrid = true, showLabel = false, stackMode = false, smoothLine = true, dualYAxis = false, leftAxisName = '', rightAxisName = '', seriesConfig = null, - xAxisName = '', xAxisNameLoc = 'end', xLabelRotate = 0, width = 600 + leftAxisNameLoc = 'vertical', rightAxisNameLoc = 'vertical', + xAxisName = '', xAxisNameLoc = 'middle', xLabelRotate = 0, width = 600 } = cfg; const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff'; @@ -1413,6 +1497,9 @@ function buildCombineChartOption(dataText, cfg) { } } + // 系列细分样式:柱状(实体/空心/阴影) / 折线(实线/虚线/点线) + applySeriesStyle(s, type, color, sc.style); + if (showLabel) { s.label = { show: true, @@ -1430,7 +1517,7 @@ function buildCombineChartOption(dataText, cfg) { return s; }); - // 双Y轴图例放到各自轴上方(左靠左、右靠右),不再占用两侧空白 + // 双Y轴图例放到各自轴上方(左靠左、右靠右),不再占用两侧空白;图标按系列类型自动 let legendCfg, gridLeftCfg, gridRightCfg, dualGridTop = null; if (dualYAxis && showLegend) { const left = [], right = []; @@ -1438,10 +1525,11 @@ function buildCombineChartOption(dataText, cfg) { const sc = scMap[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 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, @@ -1450,7 +1538,7 @@ function buildCombineChartOption(dataText, cfg) { width: '49%', data: left, 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, @@ -1459,23 +1547,22 @@ function buildCombineChartOption(dataText, cfg) { width: '49%', data: right, 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%'; gridRightCfg = '4%'; - dualGridTop = legendTop + legendH + 6; + 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 ? 44 : 16; + if (dualYAxis) dualGridTop = title ? 42 : 16; } // X轴名占位:右侧末端加右 padding,轴下方居中加下 padding(避免文字被截断) @@ -1525,7 +1612,10 @@ function buildCombineChartOption(dataText, cfg) { { type: 'value', 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 }, axisTick: { show: false }, axisLabel: { color: textColor, fontSize: 11 }, @@ -1535,7 +1625,10 @@ function buildCombineChartOption(dataText, cfg) { type: 'value', name: rightAxisName || '右轴', 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 }, axisTick: { show: false }, axisLabel: { color: textColor, fontSize: 11 }, @@ -1565,7 +1658,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: '', 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() { @@ -1631,6 +1724,17 @@ function renderCombineCharts() { / +
+ + / + +
` : ''}
@@ -1676,6 +1780,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(); } @@ -1690,7 +1799,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' }; }); } @@ -1702,7 +1811,11 @@ function renderCombineSeriesConfigHTML(i) { if (!p || !p.seriesNames.length) return '

输入数据后自动显示各系列的图表类型/坐标轴

'; 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 `
${escHtml(name)} + ${dual ? ` + +
@@ -162,7 +162,18 @@ C, 20, 30, 40 /
-

在下方「系列配置」中可指定每个系列用左轴/右轴及图表类型(柱状/折线)

+
+ + / + +
+

在下方「系列配置」中可指定每个系列用左轴/右轴、图表类型(柱状/折线)及样式(实体/空心/阴影、实线/虚线/点线)

diff --git a/server.js b/server.js index c479a2e..a68a8c5 100644 --- a/server.js +++ b/server.js @@ -124,6 +124,34 @@ 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'; + } +} + function buildChartOption(params) { const { data, @@ -147,6 +175,9 @@ function buildChartOption(params) { rightAxisName = '', seriesTypes = null, seriesAxis = null, + seriesStyles = null, + leftAxisNameLoc = 'vertical', + rightAxisNameLoc = 'vertical', width = 800 } = params; @@ -280,6 +311,10 @@ 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, @@ -345,12 +380,12 @@ function buildChartOption(params) { }; } - // 双Y轴:图例放到各自轴上方(左图例靠左、右图例靠右),不再占用两侧空白 + // 双Y轴:图例放到各自轴上方(左图例靠左、右图例靠右),不再占用两侧空白;图标按系列类型自动 let legendOpt, gridTop; if (dualYAxis && showLegend) { const left = [], right = []; series.forEach(s => { if (s.yAxisIndex === 1) right.push(s.name); else left.push(s.name); }); - const legendTop = title ? 50 : 16; + const 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); @@ -367,6 +402,7 @@ function buildChartOption(params) { }; const rows = Math.max(estRows(left), estRows(right)); const legendH = rows * 22 + 6; + const needNameTopGap = (leftAxisNameLoc === 'top' || rightAxisNameLoc === 'top'); legendOpt = [ { show: left.length > 0, @@ -374,7 +410,7 @@ function buildChartOption(params) { top: legendTop, width: '49%', data: left, title: left.length > 1 ? { text: '左轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined, - textStyle: { color: textColor, fontSize: 12 }, itemGap: 14, icon: 'roundRect' + textStyle: { color: textColor, fontSize: 12 }, itemGap: 14 }, { show: right.length > 0, @@ -382,17 +418,16 @@ function buildChartOption(params) { top: legendTop, width: '49%', data: right, title: right.length > 1 ? { text: '右轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined, - textStyle: { color: textColor, fontSize: 12 }, itemGap: 14, icon: 'roundRect' + textStyle: { color: textColor, fontSize: 12 }, itemGap: 14 } ]; - gridTop = legendTop + legendH + 8; + gridTop = legendTop + legendH + (needNameTopGap ? 24 : 8); } else { legendOpt = showLegend ? { show: true, - top: title ? 50 : 15, + top: title ? 44 : 10, textStyle: { color: textColor, fontSize: 12 }, - itemGap: 20, - icon: 'roundRect' + itemGap: 20 } : { show: false }; gridTop = showLegend ? (title ? 90 : 60) : (title ? 60 : 30); } @@ -435,7 +470,10 @@ function buildChartOption(params) { { type: 'value', 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 }, axisTick: { show: false }, axisLabel: { color: textColor, fontSize: 11 }, @@ -451,7 +489,10 @@ function buildChartOption(params) { type: 'value', name: rightAxisName || '右轴', 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 }, axisTick: { show: false }, axisLabel: { color: textColor, fontSize: 11 }, @@ -560,6 +601,9 @@ 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', rowsAsSeries: req.query.rowsAsSeries === 'true', width: parseInt(req.query.width) || 800, height: parseInt(req.query.height) || 500, @@ -1077,7 +1121,7 @@ app.get('/api/health', (req, res) => { res.json({ status: 'ok', service: 'data-chart-tool', - version: '1.18.0', + version: '1.19.0', endpoints: { 'POST /api/chart': '生成图表图片(JSON body)', 'GET /api/chart': '生成图表图片(URL 参数)', @@ -1097,7 +1141,7 @@ app.get('/api/health', (req, res) => { app.get('/api/docs', (req, res) => { res.json({ name: '数据可视化图表生成器 API', - version: '1.18.0', + version: '1.19.0', endpoints: [ { method: 'POST', @@ -1129,6 +1173,9 @@ 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=轴上部横排' }, rowsAsSeries: { type: 'boolean', default: false, description: '数据方向:false=列=系列(第一列是横坐标,每列一个系列);true=行=系列(第一行是横坐标,每行一个系列)' } }, returns: 'image/png',