diff --git a/API.md b/API.md index 801be9e..97012f8 100644 --- a/API.md +++ b/API.md @@ -45,6 +45,9 @@ Content-Type: application/json | `leftLabel` | string | — | `"左侧"` | 左侧区域标签 | | `rightLabel` | string | — | `"右侧"` | 右侧区域标签 | | `splitStyle` | string | — | `"solid"` | 分割线样式:`solid` / `dashed` / `dotted` | +| `dualYAxis` | boolean | — | `false` | 启用双Y轴(左右量度不同) | +| `rightAxisSeries` | array | — | `null` | 右轴系列名列表,如 `["利润"]`(未列出的系列用左轴) | +| `rightAxisName` | string | — | `""` | 右轴名称 | | `width` | number | — | `800` | 图片宽度(px) | | `height` | number | — | `500` | 图片高度(px) | | `pixelRatio` | number | — | `2` | 像素倍率(越大越清晰) | diff --git a/README.md b/README.md index 19c3d9e..2078a43 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - **柱状图** - 支持单系列/多系列对比 - **折线图** - 支持平滑曲线、面积填充 - **混合图** - 柱状图+折线图组合展示 +- **双Y轴** - 一张图左右两个坐标轴,量度可不同,系列可指定左轴/右轴(含右轴名称) ### 📋 表格功能 - **表格图片生成** - 根据数据生成精美的表格图片 diff --git a/app.js b/app.js index 1a1c63d..dc38e12 100644 --- a/app.js +++ b/app.js @@ -3,6 +3,7 @@ let chartInstance = null; let parsedData = null; let seriesColors = []; let seriesOrder = []; +let seriesAxis = []; // 每个系列所属轴:0=左轴 1=右轴 let currentMode = 'chart'; // 'chart' or 'table' let tableImageBlob = null; @@ -131,6 +132,7 @@ function generateChart() { seriesOrder = parsedData.seriesNames.map((_, i) => i); const palette = colorPalettes[document.getElementById('themeStyle').value] || colorPalettes.default; seriesColors = parsedData.seriesNames.map((_, i) => palette[i % palette.length]); + seriesAxis = parsedData.seriesNames.map(() => 0); // 渲染系列配置 renderSeriesConfig(); @@ -172,6 +174,8 @@ function updateChart() { const stackMode = document.getElementById('stackMode').checked; const smoothLine = document.getElementById('smoothLine').checked; const enableSplit = document.getElementById('enableSplit').checked; + const dualAxis = document.getElementById('dualAxis').checked; + const rightAxisName = document.getElementById('rightAxisName').value; // 显示/隐藏分割配置 document.getElementById('splitConfig').style.display = enableSplit ? 'block' : 'none'; @@ -248,6 +252,11 @@ function updateChart() { }; } + // 双Y轴:按系列指定左右轴 + if (dualAxis) { + seriesItem.yAxisIndex = (seriesAxis[origIdx] === 1) ? 1 : 0; + } + return seriesItem; }); @@ -379,7 +388,30 @@ function updateChart() { }, axisTick: { show: false } }, - yAxis: { + yAxis: dualAxis ? [ + { + type: 'value', + name: '左轴', + nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: textColor, fontSize: 11 }, + splitLine: { + show: showGrid, + lineStyle: { color: theme === 'dark' ? '#333' : '#f0f0f0', type: 'dashed' } + } + }, + { + type: 'value', + name: rightAxisName || '右轴', + position: 'right', + nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 4, 0, 0] }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: textColor, fontSize: 11 }, + splitLine: { show: false } + } + ] : { type: 'value', axisLine: { show: false }, axisTick: { show: false }, @@ -466,6 +498,13 @@ function renderSeriesConfig() { seriesOrder.forEach((origIdx, displayIdx) => { const name = parsedData.seriesNames[origIdx]; const color = seriesColors[origIdx]; + const dualAxis = document.getElementById('dualAxis').checked; + const axisSelect = dualAxis ? ` + + ` : ''; const item = document.createElement('div'); item.className = 'series-item'; @@ -481,6 +520,7 @@ function renderSeriesConfig() { + ${axisSelect} `; // 拖拽事件 @@ -566,6 +606,19 @@ function updateSeriesType(origIdx, type) { updateChart(); } +// ===== 双Y轴 ===== +function onDualAxisChange() { + const on = document.getElementById('dualAxis').checked; + document.getElementById('rightAxisGroup').style.display = on ? 'block' : 'none'; + renderSeriesConfig(); // 显示/隐藏系列轴选择 + updateChart(); +} + +function updateSeriesAxis(origIdx, axis) { + seriesAxis[origIdx] = parseInt(axis) || 0; + updateChart(); +} + // ===== 工具函数 ===== function adjustColor(hex, amount) { hex = hex.replace('#', ''); diff --git a/index.html b/index.html index 5b1e549..937a29d 100644 --- a/index.html +++ b/index.html @@ -100,8 +100,15 @@ C, 20, 30, 40 + + + diff --git a/server.js b/server.js index efbb292..8a2bbcb 100644 --- a/server.js +++ b/server.js @@ -113,7 +113,10 @@ function buildChartOption(params) { leftLabel = '左侧', rightLabel = '右侧', splitStyle = 'solid', - seriesColors: customColors = null + seriesColors: customColors = null, + dualYAxis = false, + rightAxisSeries = null, + rightAxisName = '' } = params; const parsedData = parseData(data); @@ -148,6 +151,12 @@ function buildChartOption(params) { } }; + // 双Y轴:rightAxisSeries 指定右轴系列名 + if (dualYAxis) { + const rightNames = Array.isArray(rightAxisSeries) ? rightAxisSeries : (rightAxisSeries ? [rightAxisSeries] : []); + seriesItem.yAxisIndex = rightNames.includes(name) ? 1 : 0; + } + if (stackMode) { seriesItem.stack = 'total'; } @@ -159,8 +168,7 @@ function buildChartOption(params) { seriesItem.areaStyle = theme === 'gradient' ? { opacity: 0.15 } : undefined; } - if (type === 'bar') { - seriesItem.barMaxWidth = 40; + if (type === 'bar') {seriesItem.barMaxWidth = 40; seriesItem.itemStyle.borderRadius = stackMode ? [0, 0, 0, 0] : [4, 4, 0, 0]; } @@ -269,7 +277,33 @@ function buildChartOption(params) { }, axisTick: { show: false } }, - yAxis: { + yAxis: dualYAxis ? [ + { + type: 'value', + name: '左轴', + nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: textColor, fontSize: 11 }, + splitLine: { + show: showGrid, + lineStyle: { + color: theme === 'dark' ? '#333' : '#f0f0f0', + type: 'dashed' + } + } + }, + { + type: 'value', + name: rightAxisName || '右轴', + position: 'right', + nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 4, 0, 0] }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: textColor, fontSize: 11 }, + splitLine: { show: false } + } + ] : { type: 'value', axisLine: { show: false }, axisTick: { show: false }, @@ -366,6 +400,9 @@ app.get('/api/chart', (req, res) => { leftLabel: req.query.leftLabel || '左侧', rightLabel: req.query.rightLabel || '右侧', splitStyle: req.query.splitStyle || 'solid', + dualYAxis: req.query.dualYAxis === 'true', + rightAxisSeries: req.query.rightAxisSeries ? req.query.rightAxisSeries.split(',') : null, + rightAxisName: req.query.rightAxisName || '', width: parseInt(req.query.width) || 800, height: parseInt(req.query.height) || 500, format: req.query.format || 'png', @@ -882,7 +919,7 @@ app.get('/api/health', (req, res) => { res.json({ status: 'ok', service: 'data-chart-tool', - version: '1.10.1', + version: '1.11.0', endpoints: { 'POST /api/chart': '生成图表图片(JSON body)', 'GET /api/chart': '生成图表图片(URL 参数)', @@ -897,7 +934,7 @@ app.get('/api/health', (req, res) => { app.get('/api/docs', (req, res) => { res.json({ name: '数据可视化图表生成器 API', - version: '1.10.1', + version: '1.11.0', endpoints: [ { method: 'POST', @@ -922,7 +959,10 @@ app.get('/api/docs', (req, res) => { width: { type: 'number', default: 800, description: '图片宽度(px)' }, height: { type: 'number', default: 500, description: '图片高度(px)' }, format: { type: 'string', default: 'png', options: ['png'], description: '输出格式' }, - pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' } + pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' }, + dualYAxis: { type: 'boolean', default: false, description: '是否启用双Y轴(左右量度不同)' }, + rightAxisSeries: { type: 'array', default: null, description: '右轴系列名列表(如 ["利润"],指定哪些系列用右轴)' }, + rightAxisName: { type: 'string', default: '', description: '右轴名称' } }, returns: 'image/png', example: { diff --git a/style.css b/style.css index 069fc44..4aa34de 100644 --- a/style.css +++ b/style.css @@ -719,6 +719,14 @@ input[type="range"]::-moz-range-thumb { } .btn-sm { - padding: 6px 12px; - font-size: 0.82rem; + padding: 4px 10px; + font-size: 0.78rem; +} + +/* 导出设置折叠栏下载按钮(更小,避免挤占) */ +.export-header .btn-sm { + padding: 3px 8px; + font-size: 0.74rem; + white-space: nowrap; + flex-shrink: 0; }