From bc8284d30ff918a49ae26d9526aa3df1dd9578ab Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Sun, 23 Aug 2026 11:06:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=8CY=E8=BD=B4=E5=9B=BE=E4=BE=8B?= =?UTF-8?q?=E6=94=BE=E5=88=B0=E5=90=84=E8=87=AA=E8=BD=B4=E4=B8=8A=E6=96=B9?= =?UTF-8?q?=E9=9D=A0=E4=B8=A4=E8=BE=B9=E5=AF=B9=E9=BD=90=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=8D=A0=E7=94=A8=E4=B8=A4=E4=BE=A7=E7=A9=BA=E7=99=BD?= =?UTF-8?q?=E5=8C=BA=20v1.18.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主图表/多图合并/服务端API 三处统一:左轴图例横排靠左、右轴图例横排靠右,放在各自轴上方 - grid 左右不再为侧边图例让位,绘图区占满整图宽度 - 按实际宽度估算图例换行行数,grid 顶部相应让位避免重叠 --- README.md | 4 +-- app.js | 81 ++++++++++++++++++++++++++++++++++--------------------- server.js | 70 +++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 112 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 984b9a7..cb926a2 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.17.1-green.svg) +![Version](https://img.shields.io/badge/version-1.18.0-green.svg) ## ✨ 功能特性 @@ -13,7 +13,7 @@ - **混合图** - 柱状图+折线图组合展示 - **饼图** - 环形占比展示(第一列=名称,第一系列=数值) - **雷达图** - 多维度对比(第一列=维度,每个系列=一个多边形) -- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线);开启双轴时**图例自动按左右轴分组,放到对应轴边上**(左轴系列图例在左侧、右轴系列图例在右侧,附「左轴/右轴」分组标题),看图一目了然 +- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线);开启双轴时**图例放在各自轴上方、各自往两边对齐**(左轴系列图例靠左、右轴系列图例靠右,附「左轴/右轴」分组标题),不再占用两侧空白区,绘图区占满整图宽度,看图一目了然 - **X轴设置** - 可填写**横坐标轴名**,**轴名位置可选**(右侧末端 / 轴下方居中),选轴名时自动预留空间不再截字;刻度文字方向**默认水平(0°)**,支持**下拉档位(每10°)** + **手动输入任意度数(0-90°)**,解决长类别名重叠问题 - **数据方向(行=系列)** - 支持"行=系列"布局:第一行是横坐标,每行一个系列,每行可独立设置图表类型(柱状/折线)与左右轴量度(如:营收行=柱状左轴,销量行=折线右轴) diff --git a/app.js b/app.js index 3d5956c..68220ba 100644 --- a/app.js +++ b/app.js @@ -463,34 +463,42 @@ function updateChart() { window._splitConfig = null; } - // 图表配置:双Y轴图例按左右轴分组放到对应轴边上,grid 相应让位 - let legendConfig, gridLeftCfg, gridRightCfg; + // 图表配置:双Y轴图例放到各自轴上方(左图例靠左、右图例靠右),不再占用两侧空白,grid 全宽可用 + let legendConfig, gridLeftCfg, gridRightCfg, dualGridTop = null; if (dualAxis && showLegend) { const left = [], right = []; seriesOrder.forEach(origIdx => { const name = parsedData.seriesNames[origIdx]; (seriesAxis[origIdx] === 1 ? right : left).push(name); }); + const legendTop = title ? 50 : 16; + const chartW = chartInstance ? chartInstance.getWidth() : 800; + const availW = Math.max(100, (chartW - 20) / 2); + const rows = Math.max(estimateLegendRows(left, 12, availW), estimateLegendRows(right, 12, availW)); + const legendH = rows * 22 + 6; // 每行约 22px legendConfig = [ { show: left.length > 0, - orient: 'vertical', left: 8, - top: title ? 56 : 24, + orient: 'horizontal', left: 8, + top: legendTop, + width: '49%', data: left, title: left.length > 1 ? { text: '左轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined, - textStyle: { color: textColor, fontSize: 12 }, itemGap: 10, icon: 'roundRect' + textStyle: { color: textColor, fontSize: 12 }, itemGap: 14, icon: 'roundRect' }, { show: right.length > 0, - orient: 'vertical', right: 8, - top: title ? 56 : 24, + orient: 'horizontal', right: 8, + top: legendTop, + width: '49%', data: right, title: right.length > 1 ? { text: '右轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined, - textStyle: { color: textColor, fontSize: 12 }, itemGap: 10, icon: 'roundRect' + textStyle: { color: textColor, fontSize: 12 }, itemGap: 14, icon: 'roundRect' } ]; - gridLeftCfg = measureLegendWidth(left) + 30; - gridRightCfg = measureLegendWidth(right) + 30; + gridLeftCfg = '3%'; + gridRightCfg = '4%'; + dualGridTop = legendTop + legendH + 8; } else { legendConfig = showLegend ? { show: true, @@ -501,6 +509,7 @@ function updateChart() { } : { show: false }; gridLeftCfg = '3%'; gridRightCfg = '4%'; + if (dualAxis) dualGridTop = title ? 52 : 24; } // X轴名占位:右侧末端需加右 padding,轴下方居中需加下 padding(避免文字被截断) @@ -554,7 +563,7 @@ function updateChart() { left: gridLeftCfg, right: gridRightCfg, bottom: gridBottomCfg, - top: dualAxis ? (title ? 52 : 24) : (showLegend ? (title ? 90 : 60) : (title ? 60 : 30)), + top: dualGridTop !== null ? dualGridTop : (showLegend ? (title ? 90 : 60) : (title ? 60 : 30)), containLabel: true }, xAxis: { @@ -846,16 +855,20 @@ function measureTextWidth(text, fontSize) { return ctx.measureText(text).width; } -// 测量图例文字宽度(用于双轴侧边图例的 grid 让位) -function measureLegendWidth(names, fontSize) { - fontSize = fontSize || 12; +// 估算水平图例的换行行数(双轴顶部图例用,避免图例与绘图区重叠) +function estimateLegendRows(names, fontSize, availWidth) { if (!names || !names.length) return 0; const c = document.createElement('canvas'); const ctx = c.getContext('2d'); ctx.font = `${fontSize}px sans-serif`; - let max = 0; - names.forEach(n => { const w = ctx.measureText(n).width; if (w > max) max = w; }); - return Math.ceil(max + 44); // 图标(14)+间距(6)+边距(24) + const icon = 14, sw = 6, ig = 14; // 图标宽度 + 图标与文字间距 + itemGap + let cur = 0, rows = 1; + names.forEach(n => { + const itemW = icon + sw + ctx.measureText(n).width; + if (cur && cur + ig + itemW > availWidth) { rows++; cur = itemW; } + else cur += itemW + (cur ? ig : 0); + }); + return rows; } function adjustColor(hex, amount) { @@ -1293,7 +1306,7 @@ function buildCombineChartOption(dataText, cfg) { showLegend = true, showGrid = true, showLabel = false, stackMode = false, smoothLine = true, dualYAxis = false, leftAxisName = '', rightAxisName = '', seriesConfig = null, - xAxisName = '', xAxisNameLoc = 'end', xLabelRotate = 0 + xAxisName = '', xAxisNameLoc = 'end', xLabelRotate = 0, width = 600 } = cfg; const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff'; @@ -1417,34 +1430,41 @@ function buildCombineChartOption(dataText, cfg) { return s; }); - // 双Y轴图例按左右轴分组放到对应轴边上,grid 相应让位 - let legendCfg, gridLeftCfg, gridRightCfg; + // 双Y轴图例放到各自轴上方(左靠左、右靠右),不再占用两侧空白 + let legendCfg, gridLeftCfg, gridRightCfg, dualGridTop = null; if (dualYAxis && showLegend) { const left = [], right = []; parsed.seriesNames.forEach(name => { const sc = scMap[name] || {}; ((sc.axis === 1 || sc.axis === '1') ? right : left).push(name); }); + const legendTop = title ? 42 : 12; + const availW = Math.max(80, (width - 16) / 2); + const rows = Math.max(estimateLegendRows(left, 11, availW), estimateLegendRows(right, 11, availW)); + const legendH = rows * 20 + 4; // 每行约 20px legendCfg = [ { show: left.length > 0, - orient: 'vertical', left: 6, - top: title ? 44 : 12, + orient: 'horizontal', left: 6, + top: legendTop, + width: '49%', data: left, title: left.length > 1 ? { text: '左轴', textStyle: { color: textColor, fontSize: 10, fontWeight: 600 } } : undefined, - textStyle: { color: textColor, fontSize: 11 }, itemGap: 8, icon: 'roundRect' + textStyle: { color: textColor, fontSize: 11 }, itemGap: 10, icon: 'roundRect' }, { show: right.length > 0, - orient: 'vertical', right: 6, - top: title ? 44 : 12, + orient: 'horizontal', right: 6, + top: legendTop, + width: '49%', data: right, title: right.length > 1 ? { text: '右轴', textStyle: { color: textColor, fontSize: 10, fontWeight: 600 } } : undefined, - textStyle: { color: textColor, fontSize: 11 }, itemGap: 8, icon: 'roundRect' + textStyle: { color: textColor, fontSize: 11 }, itemGap: 10, icon: 'roundRect' } ]; - gridLeftCfg = Math.min(measureLegendWidth(left, 11) + 24, 200); - gridRightCfg = Math.min(measureLegendWidth(right, 11) + 24, 200); + gridLeftCfg = '3%'; + gridRightCfg = '4%'; + dualGridTop = legendTop + legendH + 6; } else { legendCfg = showLegend ? { show: true, @@ -1455,6 +1475,7 @@ function buildCombineChartOption(dataText, cfg) { } : { show: false }; gridLeftCfg = '3%'; gridRightCfg = '4%'; + if (dualYAxis) dualGridTop = title ? 44 : 16; } // X轴名占位:右侧末端加右 padding,轴下方居中加下 padding(避免文字被截断) @@ -1486,7 +1507,7 @@ function buildCombineChartOption(dataText, cfg) { legend: legendCfg, grid: { left: gridLeftCfg, right: gridRightCfg, bottom: gridBottomCfg, - top: dualYAxis ? (title ? 44 : 16) : (showLegend ? (title ? 70 : 45) : (title ? 50 : 30)), + top: dualGridTop !== null ? dualGridTop : (showLegend ? (title ? 70 : 45) : (title ? 50 : 30)), containLabel: true }, xAxis: { @@ -1803,7 +1824,7 @@ function generateCombine() { rowsAsSeries: !!c.rowsAsSeries, dualYAxis: !!c.dualYAxis, leftAxisName: c.leftAxisName || '', rightAxisName: c.rightAxisName || '', xAxisName: c.xAxisName || '', xAxisNameLoc: c.xAxisNameLoc || 'end', xLabelRotate: parseInt(c.xLabelRotate) || 0, - seriesConfig: c.seriesConfig + seriesConfig: c.seriesConfig, width: w }); if (!option) { reject(new Error('数据格式错误')); return; } const holder = document.createElement('div'); diff --git a/server.js b/server.js index 4bf4d66..c479a2e 100644 --- a/server.js +++ b/server.js @@ -146,7 +146,8 @@ function buildChartOption(params) { leftAxisName = '', rightAxisName = '', seriesTypes = null, - seriesAxis = null + seriesAxis = null, + width = 800 } = params; const parsedData = parseData(data, params.rowsAsSeries); @@ -344,6 +345,58 @@ function buildChartOption(params) { }; } + // 双Y轴:图例放到各自轴上方(左图例靠左、右图例靠右),不再占用两侧空白 + let legendOpt, gridTop; + if (dualYAxis && showLegend) { + const left = [], right = []; + series.forEach(s => { if (s.yAxisIndex === 1) right.push(s.name); else left.push(s.name); }); + const legendTop = title ? 50 : 16; + const availW = Math.max(100, (width - 20) / 2); + // 粗略估算文字宽度:中文≈字号宽,ASCII≈字号*0.6 + const textW = (n) => [...String(n)].reduce((w, ch) => w + (ch.charCodeAt(0) > 255 ? 12 : 7), 0); + const estRows = (names) => { + if (!names || !names.length) return 0; + const icon = 14, sw = 6, ig = 14; + let cur = 0, rows = 1; + names.forEach(n => { + const itemW = icon + sw + textW(n); + if (cur && cur + ig + itemW > availW) { rows++; cur = itemW; } + else cur += itemW + (cur ? ig : 0); + }); + return rows; + }; + const rows = Math.max(estRows(left), estRows(right)); + const legendH = rows * 22 + 6; + legendOpt = [ + { + show: left.length > 0, + orient: 'horizontal', left: 8, + top: legendTop, width: '49%', + data: left, + title: left.length > 1 ? { text: '左轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined, + textStyle: { color: textColor, fontSize: 12 }, itemGap: 14, icon: 'roundRect' + }, + { + show: right.length > 0, + orient: 'horizontal', right: 8, + top: legendTop, width: '49%', + data: right, + title: right.length > 1 ? { text: '右轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined, + textStyle: { color: textColor, fontSize: 12 }, itemGap: 14, icon: 'roundRect' + } + ]; + gridTop = legendTop + legendH + 8; + } else { + legendOpt = showLegend ? { + show: true, + top: title ? 50 : 15, + textStyle: { color: textColor, fontSize: 12 }, + itemGap: 20, + icon: 'roundRect' + } : { show: false }; + gridTop = showLegend ? (title ? 90 : 60) : (title ? 60 : 30); + } + // 完整 option const option = { backgroundColor: bgColor, @@ -358,18 +411,12 @@ function buildChartOption(params) { } } : undefined, tooltip: { show: false }, - legend: showLegend ? { - show: true, - top: title ? 50 : 15, - textStyle: { color: textColor, fontSize: 12 }, - itemGap: 20, - icon: 'roundRect' - } : { show: false }, + legend: legendOpt, grid: { left: '3%', right: '4%', bottom: '3%', - top: showLegend ? (title ? 90 : 60) : (title ? 60 : 30), + top: gridTop, containLabel: true }, xAxis: { @@ -1030,7 +1077,7 @@ app.get('/api/health', (req, res) => { res.json({ status: 'ok', service: 'data-chart-tool', - version: '1.17.1', + version: '1.18.0', endpoints: { 'POST /api/chart': '生成图表图片(JSON body)', 'GET /api/chart': '生成图表图片(URL 参数)', @@ -1050,7 +1097,7 @@ app.get('/api/health', (req, res) => { app.get('/api/docs', (req, res) => { res.json({ name: '数据可视化图表生成器 API', - version: '1.17.1', + version: '1.18.0', endpoints: [ { method: 'POST', @@ -1282,6 +1329,7 @@ app.post('/api/combine', async (req, res) => { const renderSub = async (params, w, h) => { const p = { ...params }; if (sharedLegend) p.showLegend = false; + p.width = w; // 双轴顶部图例换行估算用实际子图宽度 const option = buildChartOption(p); const c = createCanvas(w * pixelRatio, h * pixelRatio); const chart = echarts.init(c, null, {