feat: 数据方向(行=系列)+每系列类型/双轴 + 多图大标题/共享图例 v1.16.0
- 图表模式新增'数据方向':列=系列(默认)/行=系列(第一行是横坐标,每行一个系列),每行可独立设柱状/折线 + 左右轴量度 - 后端 buildChartOption/GET /api/chart 支持 rowsAsSeries + seriesAxis(按系列下标指定左右轴) - 多图合并每张小图新增:行=系列、双Y轴(左右量度不同)、每系列独立图表类型与坐标轴配置 - 多图合并新增图片大标题(bigTitle),居中显示在顶部 - 多图合并新增图例方式(legendMode):own 各自图例(默认)/shared-top 共用图例顶部/shared-bottom 共用图例底部,共用时各子图隐藏自己的图例、系列合并去重、超宽自动换行 - /api/combine 后端同步支持 bigTitle/legendMode(含深色主题文字适配) - 收藏功能完整支持新字段(combine 大标题/图例方式/每系列配置,chart 数据方向),编辑恢复正常 - 文档/API.md/README 更新,版本 1.16.0
This commit is contained in:
@@ -45,8 +45,8 @@ const colorPalettes = {
|
||||
mint: ['#00bfa5', '#26a69a', '#4db6ac', '#80cbc4', '#b2dfdb', '#00897b', '#00796b', '#004d40', '#009688']
|
||||
};
|
||||
|
||||
// ===== 数据解析(与前端一致) =====
|
||||
function parseData(rawText) {
|
||||
// ===== 数据解析(rowsAsSeries=true 时“行=系列”:第一行是横坐标,每行一个系列) =====
|
||||
function parseData(rawText, rowsAsSeries) {
|
||||
const lines = rawText.trim().split('\n').filter(l => l.trim());
|
||||
if (lines.length < 2) {
|
||||
throw new Error('数据至少需要包含表头和一行数据');
|
||||
@@ -68,6 +68,32 @@ function parseData(rawText) {
|
||||
const categories = [];
|
||||
const seriesData = {};
|
||||
|
||||
if (rowsAsSeries) {
|
||||
// 行=系列:第一列=系列名,表头除第一列外=横坐标
|
||||
const names = [];
|
||||
const nameSet = {};
|
||||
rows.slice(1).forEach(r => {
|
||||
const n = ((r && r[0]) || '').trim();
|
||||
if (n && !nameSet[n]) { nameSet[n] = true; names.push(n); }
|
||||
});
|
||||
names.forEach(n => { seriesData[n] = []; });
|
||||
for (let j = 1; j < headers.length; j++) {
|
||||
categories.push(headers[j]);
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const r = rows[i];
|
||||
const name = ((r && r[0]) || '').trim();
|
||||
if (!seriesData[name]) continue;
|
||||
const val = parseFloat(r[j]);
|
||||
seriesData[name].push(isNaN(val) ? 0 : val);
|
||||
}
|
||||
}
|
||||
return {
|
||||
categories,
|
||||
seriesNames: names,
|
||||
seriesData
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = 1; i < headers.length; i++) {
|
||||
seriesData[headers[i]] = [];
|
||||
}
|
||||
@@ -119,10 +145,11 @@ function buildChartOption(params) {
|
||||
rightAxisSeries = null,
|
||||
leftAxisName = '',
|
||||
rightAxisName = '',
|
||||
seriesTypes = null
|
||||
seriesTypes = null,
|
||||
seriesAxis = null
|
||||
} = params;
|
||||
|
||||
const parsedData = parseData(data);
|
||||
const parsedData = parseData(data, params.rowsAsSeries);
|
||||
const palette = colorPalettes[theme] || colorPalettes.default;
|
||||
const seriesColorsArr = customColors || parsedData.seriesNames.map((_, i) => palette[i % palette.length]);
|
||||
|
||||
@@ -225,10 +252,16 @@ function buildChartOption(params) {
|
||||
}
|
||||
};
|
||||
|
||||
// 双Y轴:rightAxisSeries 指定右轴系列名
|
||||
// 双Y轴:rightAxisSeries 按名称指定右轴,seriesAxis 按系列下标(0左 1右)
|
||||
if (dualYAxis) {
|
||||
const rightNames = Array.isArray(rightAxisSeries) ? rightAxisSeries : (rightAxisSeries ? [rightAxisSeries] : []);
|
||||
seriesItem.yAxisIndex = rightNames.includes(name) ? 1 : 0;
|
||||
let axisIdx = 0;
|
||||
if (Array.isArray(seriesAxis) && seriesAxis[idx] !== undefined && seriesAxis[idx] !== null && seriesAxis[idx] !== '') {
|
||||
axisIdx = Number(seriesAxis[idx]) === 1 ? 1 : 0;
|
||||
} else {
|
||||
const rightNames = Array.isArray(rightAxisSeries) ? rightAxisSeries : (rightAxisSeries ? [rightAxisSeries] : []);
|
||||
axisIdx = rightNames.includes(name) ? 1 : 0;
|
||||
}
|
||||
seriesItem.yAxisIndex = axisIdx;
|
||||
}
|
||||
|
||||
if (stackMode) {
|
||||
@@ -479,6 +512,8 @@ app.get('/api/chart', (req, res) => {
|
||||
leftAxisName: req.query.leftAxisName || '',
|
||||
rightAxisName: req.query.rightAxisName || '',
|
||||
seriesTypes: req.query.seriesTypes ? req.query.seriesTypes.split(',') : null,
|
||||
seriesAxis: req.query.seriesAxis ? req.query.seriesAxis.split(',').map(Number) : null,
|
||||
rowsAsSeries: req.query.rowsAsSeries === 'true',
|
||||
width: parseInt(req.query.width) || 800,
|
||||
height: parseInt(req.query.height) || 500,
|
||||
format: req.query.format || 'png',
|
||||
@@ -995,7 +1030,7 @@ app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'data-chart-tool',
|
||||
version: '1.15.0',
|
||||
version: '1.16.0',
|
||||
endpoints: {
|
||||
'POST /api/chart': '生成图表图片(JSON body)',
|
||||
'GET /api/chart': '生成图表图片(URL 参数)',
|
||||
@@ -1015,7 +1050,7 @@ app.get('/api/health', (req, res) => {
|
||||
app.get('/api/docs', (req, res) => {
|
||||
res.json({
|
||||
name: '数据可视化图表生成器 API',
|
||||
version: '1.15.0',
|
||||
version: '1.16.0',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -1045,7 +1080,9 @@ app.get('/api/docs', (req, res) => {
|
||||
rightAxisSeries: { type: 'array', default: null, description: '右轴系列名列表(如 ["利润"],指定哪些系列用右轴)' },
|
||||
leftAxisName: { 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)' },
|
||||
rowsAsSeries: { type: 'boolean', default: false, description: '数据方向:false=列=系列(第一列是横坐标,每列一个系列);true=行=系列(第一行是横坐标,每行一个系列)' }
|
||||
},
|
||||
returns: 'image/png',
|
||||
example: {
|
||||
@@ -1127,15 +1164,18 @@ app.get('/api/docs', (req, res) => {
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/combine',
|
||||
description: '将多张图表合并到一张图片中(支持横排/竖排)',
|
||||
description: '将多张图表合并到一张图片中(支持横排/竖排/网格,支持整图大标题与共享图例)',
|
||||
'Content-Type': 'application/json',
|
||||
params: {
|
||||
charts: { type: 'array', required: true, description: '图表配置数组(N 张),每项同 /api/chart 参数;兼容 chart1 + chart2' },
|
||||
charts: { type: 'array', required: true, description: '图表配置数组(N 张),每项同 /api/chart 参数(含 seriesTypes/seriesAxis/dualYAxis 等);兼容 chart1 + chart2' },
|
||||
direction: { type: 'string', default: 'horizontal', options: ['horizontal', 'vertical', 'grid'], description: '排布方式:horizontal 横排(单行)/ vertical 竖排(单列)/ grid 多行多列' },
|
||||
cols: { type: 'number', default: 2, description: '多行多列时的每行列数(仅 grid 生效)' },
|
||||
gap: { type: 'number', default: 24, description: '图间距(px)' },
|
||||
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' },
|
||||
background: { type: 'string', default: '#ffffff', description: '背景色' }
|
||||
background: { type: 'string', default: '#ffffff', description: '背景色' },
|
||||
bigTitle: { type: 'string', default: '', description: '整张图片的大标题(可空)' },
|
||||
legendMode: { type: 'string', default: 'own', options: ['own', 'shared-top', 'shared-bottom'], description: '图例方式:own 每个小图各自图例 / shared-top 共用图例放顶部 / shared-bottom 共用图例放底部(共用时各子图隐藏自己的图例)' },
|
||||
theme: { type: 'string', default: 'default', description: '大标题/共享图例文字颜色所属主题(深色用 dark)' }
|
||||
},
|
||||
returns: 'image/png',
|
||||
example: {
|
||||
@@ -1187,6 +1227,27 @@ app.get('/api/docs', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 多图合并:收集共享图例条目(按名称去重) =====
|
||||
function combineLegendItems(charts) {
|
||||
const items = [];
|
||||
const seen = {};
|
||||
charts.forEach(c => {
|
||||
if (!c || !c.data) return;
|
||||
let p;
|
||||
try { p = parseData(c.data, c.rowsAsSeries); } catch (e) { return; }
|
||||
if (!p || !p.seriesNames) return;
|
||||
const palette = colorPalettes[c.theme] || colorPalettes.default;
|
||||
const custom = Array.isArray(c.seriesColors) ? c.seriesColors : null;
|
||||
p.seriesNames.forEach((name, idx) => {
|
||||
if (!seen[name]) {
|
||||
seen[name] = true;
|
||||
items.push({ name, color: custom ? (custom[idx] || palette[idx % palette.length]) : palette[idx % palette.length] });
|
||||
}
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
// ===== API: 多图合并生成图片 =====
|
||||
app.post('/api/combine', async (req, res) => {
|
||||
try {
|
||||
@@ -1210,11 +1271,18 @@ app.post('/api/combine', async (req, res) => {
|
||||
const gap = parseInt(body.gap) || 24;
|
||||
const pixelRatio = parseInt(body.pixelRatio) || 2;
|
||||
const background = body.background || '#ffffff';
|
||||
const bigTitle = String(body.bigTitle || '');
|
||||
const legendMode = ['shared-top', 'shared-bottom'].includes(body.legendMode) ? body.legendMode : 'own';
|
||||
const sharedLegend = legendMode !== 'own';
|
||||
const theme = body.theme || 'default';
|
||||
const textColor = theme === 'dark' ? '#e0e0e0' : '#333333';
|
||||
|
||||
// 渲染单个子图(直接按目标尺寸渲染,避免二次缩放损失)
|
||||
// 渲染单个子图(共享图例时隐藏各子图自己的图例;直接按目标尺寸渲染,避免二次缩放损失)
|
||||
// 注意:必须先 toBuffer 再 chart.dispose(),dispose 会清空 canvas 内容!
|
||||
const renderSub = async (params, w, h) => {
|
||||
const option = buildChartOption(params);
|
||||
const p = { ...params };
|
||||
if (sharedLegend) p.showLegend = false;
|
||||
const option = buildChartOption(p);
|
||||
const c = createCanvas(w * pixelRatio, h * pixelRatio);
|
||||
const chart = echarts.init(c, null, {
|
||||
renderer: 'canvas',
|
||||
@@ -1233,10 +1301,8 @@ app.post('/api/combine', async (req, res) => {
|
||||
renderSub(c, parseInt(c.width) || 640, parseInt(c.height) || 420)
|
||||
));
|
||||
|
||||
let W, H;
|
||||
const canvas = createCanvas(1, 1);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// 基础布局(不含大标题/共享图例):计算子图单元格位置
|
||||
let W, H, cells = [];
|
||||
if (direction === 'grid') {
|
||||
// 多行多列网格:所有子图统一 contain 到最大单元格,按列填充
|
||||
const rows = Math.ceil(subs.length / cols);
|
||||
@@ -1244,53 +1310,122 @@ app.post('/api/combine', async (req, res) => {
|
||||
const cellH = Math.max(...subs.map(s => s.h));
|
||||
W = cols * cellW + (cols - 1) * gap;
|
||||
H = rows * cellH + (rows - 1) * gap;
|
||||
canvas.width = W * pixelRatio;
|
||||
canvas.height = H * pixelRatio;
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, W * pixelRatio, H * pixelRatio);
|
||||
subs.forEach((s, i) => {
|
||||
const r = Math.floor(i / cols), c = i % cols;
|
||||
const scale = Math.min(cellW / s.w, cellH / s.h);
|
||||
const dw = Math.round(s.w * scale), dh = Math.round(s.h * scale);
|
||||
const x = c * (cellW + gap) + Math.round((cellW - dw) / 2);
|
||||
const y = r * (cellH + gap) + Math.round((cellH - dh) / 2);
|
||||
ctx.drawImage(s.img, x * pixelRatio, y * pixelRatio, dw * pixelRatio, dh * pixelRatio);
|
||||
cells.push({
|
||||
img: s.img,
|
||||
x: c * (cellW + gap) + Math.round((cellW - dw) / 2),
|
||||
y: r * (cellH + gap) + Math.round((cellH - dh) / 2),
|
||||
w: dw, h: dh
|
||||
});
|
||||
});
|
||||
} else if (direction === 'vertical') {
|
||||
// 竖排(上下):等宽对齐
|
||||
const tw = Math.max(...subs.map(s => s.w));
|
||||
let y = 0;
|
||||
cells = subs.map(s => {
|
||||
const h = Math.round(s.h * (tw / s.w));
|
||||
const cell = { img: s.img, x: 0, y, w: tw, h };
|
||||
y += h + gap;
|
||||
return cell;
|
||||
});
|
||||
W = tw;
|
||||
H = y - gap;
|
||||
} else {
|
||||
let scaled;
|
||||
if (direction === 'vertical') {
|
||||
// 竖排(上下):等宽对齐
|
||||
const tw = Math.max(...subs.map(s => s.w));
|
||||
scaled = subs.map(s => ({ img: s.img, w: tw, h: Math.round(s.h * (tw / s.w)) }));
|
||||
W = tw;
|
||||
H = scaled.reduce((sum, s) => sum + s.h, 0) + gap * (scaled.length - 1);
|
||||
} else {
|
||||
// 横排(左右):等高对齐
|
||||
const th = Math.max(...subs.map(s => s.h));
|
||||
scaled = subs.map(s => ({ img: s.img, w: Math.round(s.w * (th / s.h)), h: th }));
|
||||
W = scaled.reduce((sum, s) => sum + s.w, 0) + gap * (scaled.length - 1);
|
||||
H = th;
|
||||
}
|
||||
// 横排(左右):等高对齐
|
||||
const th = Math.max(...subs.map(s => s.h));
|
||||
let x = 0;
|
||||
cells = subs.map(s => {
|
||||
const w = Math.round(s.w * (th / s.h));
|
||||
const cell = { img: s.img, x, y: 0, w, h: th };
|
||||
x += w + gap;
|
||||
return cell;
|
||||
});
|
||||
W = x - gap;
|
||||
H = th;
|
||||
}
|
||||
|
||||
canvas.width = W * pixelRatio;
|
||||
canvas.height = H * pixelRatio;
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, W * pixelRatio, H * pixelRatio);
|
||||
// 共享图例:按宽度换行计算
|
||||
const legendFont = 13, swatch = 14, sw = 5, ig = 18, lineH = legendFont + 10;
|
||||
let legendRows = [], legendH = 0;
|
||||
if (sharedLegend) {
|
||||
const items = combineLegendItems(charts);
|
||||
const tmp = createCanvas(10, 10);
|
||||
const tctx = tmp.getContext('2d');
|
||||
tctx.font = `${legendFont}px "Noto Sans CJK SC", sans-serif`;
|
||||
const maxW = W - 20;
|
||||
let cur = [], curW = 0;
|
||||
items.forEach(it => {
|
||||
const itemW = swatch + sw + tctx.measureText(it.name).width;
|
||||
if (cur.length && curW + ig + itemW > maxW) { legendRows.push(cur); cur = []; curW = 0; }
|
||||
cur.push(it);
|
||||
curW += itemW + (cur.length > 1 ? ig : 0);
|
||||
});
|
||||
if (cur.length) legendRows.push(cur);
|
||||
legendH = legendRows.length * lineH + 8;
|
||||
}
|
||||
|
||||
if (direction === 'vertical') {
|
||||
// 5 参数形式:drawImage(img, dx, dy, dw, dh),源整图缩放到目标矩形
|
||||
let y = 0;
|
||||
scaled.forEach(s => {
|
||||
ctx.drawImage(s.img, 0, y * pixelRatio, s.w * pixelRatio, s.h * pixelRatio);
|
||||
y += s.h + gap;
|
||||
// 大标题区高度
|
||||
const titleFont = 34;
|
||||
const titleH = bigTitle ? titleFont + 26 : 0;
|
||||
const topOffset = titleH + (legendMode === 'shared-top' ? legendH + 12 : 0);
|
||||
const bottomOffset = legendMode === 'shared-bottom' ? legendH + 12 : 0;
|
||||
const totalH = H + topOffset + bottomOffset;
|
||||
|
||||
// 创建最终画布(统一 scale,全部用逻辑坐标绘制)
|
||||
const canvas = createCanvas(W * pixelRatio, totalH * pixelRatio);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(pixelRatio, pixelRatio);
|
||||
|
||||
// 背景
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, W, totalH);
|
||||
|
||||
// 大标题
|
||||
if (bigTitle) {
|
||||
ctx.font = `bold ${titleFont}px "Noto Sans CJK SC", sans-serif`;
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(bigTitle, W / 2, (titleH - 26) / 2 + 2);
|
||||
}
|
||||
|
||||
// 绘制图例行(topY 为图例区顶部逻辑坐标)
|
||||
const drawLegendBlock = (topY) => {
|
||||
legendRows.forEach((row, ri) => {
|
||||
ctx.font = `${legendFont}px "Noto Sans CJK SC", sans-serif`;
|
||||
let totalW = 0;
|
||||
row.forEach(it => { totalW += swatch + sw + ctx.measureText(it.name).width + ig; });
|
||||
totalW -= ig;
|
||||
let cx = (W - totalW) / 2;
|
||||
row.forEach(it => {
|
||||
ctx.fillStyle = it.color;
|
||||
ctx.fillRect(cx, topY + ri * lineH + (legendFont - swatch) / 2 + 2, swatch, swatch);
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `${legendFont}px "Noto Sans CJK SC", sans-serif`;
|
||||
ctx.fillText(it.name, cx + swatch + sw, topY + ri * lineH + legendFont / 2 + 2);
|
||||
cx += swatch + sw + ctx.measureText(it.name).width + ig;
|
||||
});
|
||||
} else {
|
||||
let x = 0;
|
||||
scaled.forEach(s => {
|
||||
ctx.drawImage(s.img, x * pixelRatio, 0, s.w * pixelRatio, s.h * pixelRatio);
|
||||
x += s.w + gap;
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 顶部共享图例
|
||||
if (sharedLegend && legendMode === 'shared-top') {
|
||||
drawLegendBlock(titleH + 4);
|
||||
}
|
||||
|
||||
// 子图(整体下移 topOffset)
|
||||
cells.forEach(cell => {
|
||||
ctx.drawImage(cell.img, cell.x, cell.y + topOffset, cell.w, cell.h);
|
||||
});
|
||||
|
||||
// 底部共享图例
|
||||
if (sharedLegend && legendMode === 'shared-bottom') {
|
||||
drawLegendBlock(topOffset + H + 8);
|
||||
}
|
||||
|
||||
const buffer = canvas.toBuffer('image/png');
|
||||
|
||||
Reference in New Issue
Block a user