feat: 新增双图合并模式,支持横排/竖排将两张图合并为一张图
- 后端新增 POST /api/combine 接口(chart1/chart2 + direction/gap/pixelRatio/background)
- 前端新增 🖼️ 双图合并模式:两张图独立配置数据/标题/类型/主题
- 横排(左右等高三张图)或竖排(上下等宽),一键切换实时预览
- 修复 @napi-rs/canvas echarts 渲染需先 toBuffer 再 dispose 的关键问题
- 更新 README/API 文档
This commit is contained in:
@@ -59,6 +59,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// 默认加载第一个示例
|
||||
document.getElementById('dataInput').value = sampleDataSets[0].data;
|
||||
generateChart();
|
||||
|
||||
// 预填双图合并示例
|
||||
document.getElementById('combineData1').value = sampleDataSets[0].data;
|
||||
document.getElementById('combineData2').value = sampleDataSets[1].data;
|
||||
document.getElementById('combineTitle1').value = '季度销售对比';
|
||||
document.getElementById('combineType2').value = 'line';
|
||||
document.getElementById('combineTitle2').value = '年度增长趋势';
|
||||
generateCombine();
|
||||
});
|
||||
|
||||
// ===== 数据解析 =====
|
||||
@@ -625,16 +633,24 @@ function switchMode(mode) {
|
||||
// 更新按钮状态
|
||||
document.getElementById('btnChartMode').classList.toggle('active', mode === 'chart');
|
||||
document.getElementById('btnTableMode').classList.toggle('active', mode === 'table');
|
||||
document.getElementById('btnCombineMode').classList.toggle('active', mode === 'combine');
|
||||
|
||||
// 显示/隐藏配置区
|
||||
document.getElementById('dataInputSection').style.display = mode === 'combine' ? 'none' : 'block';
|
||||
document.getElementById('chartConfigSection').style.display = mode === 'chart' ? 'block' : 'none';
|
||||
document.getElementById('tableConfigSection').style.display = mode === 'table' ? 'block' : 'none';
|
||||
document.getElementById('seriesConfigSection').style.display = mode === 'chart' ? 'block' : 'none';
|
||||
document.getElementById('splitConfigSection').style.display = mode === 'chart' ? 'block' : 'none';
|
||||
document.getElementById('combineConfigSection').style.display = mode === 'combine' ? 'block' : 'none';
|
||||
|
||||
// 更新生成按钮
|
||||
const btnGenerate = document.getElementById('btnGenerate');
|
||||
btnGenerate.textContent = mode === 'chart' ? '🎨 生成图表' : '📋 生成表格';
|
||||
if (mode === 'combine') {
|
||||
btnGenerate.style.display = 'none'; // 双图模式用自己的按钮
|
||||
} else {
|
||||
btnGenerate.style.display = '';
|
||||
btnGenerate.textContent = mode === 'chart' ? '🎨 生成图表' : '📋 生成表格';
|
||||
}
|
||||
|
||||
// 更新导出按钮
|
||||
const btnExportSvg = document.getElementById('btnExportSvg');
|
||||
@@ -648,8 +664,10 @@ function switchMode(mode) {
|
||||
function generate() {
|
||||
if (currentMode === 'chart') {
|
||||
generateChart();
|
||||
} else {
|
||||
} else if (currentMode === 'table') {
|
||||
generateTable();
|
||||
} else {
|
||||
generateCombine();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,8 +728,10 @@ function updateTable() {
|
||||
function exportImage(format) {
|
||||
if (currentMode === 'chart') {
|
||||
exportChart(format);
|
||||
} else {
|
||||
} else if (currentMode === 'table') {
|
||||
exportTable(format);
|
||||
} else {
|
||||
exportCombine();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,11 +761,12 @@ function quickSwitchTheme(theme) {
|
||||
// 图表模式:更新主题选择器并重新渲染
|
||||
document.getElementById('themeStyle').value = theme;
|
||||
updateChart();
|
||||
} else {
|
||||
} else if (currentMode === 'table') {
|
||||
// 表格模式:更新主题选择器并重新请求
|
||||
document.getElementById('tableTheme').value = theme;
|
||||
updateTable();
|
||||
}
|
||||
// 双图合并模式:仅切换按钮高亮,不联动(两图各自独立主题)
|
||||
}
|
||||
|
||||
// ===== 同步风格按钮状态(下拉框变更时) =====
|
||||
@@ -767,3 +788,220 @@ function initThemeButtons() {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initThemeButtons();
|
||||
});
|
||||
|
||||
// ===== 双图合并 =====
|
||||
let combinedCanvas = null;
|
||||
|
||||
// 构建单个子图的 echarts option(不依赖全局状态,双图模式专用)
|
||||
function buildCombineChartOption(dataText, cfg) {
|
||||
const parsed = parseData(dataText);
|
||||
if (!parsed) return null;
|
||||
|
||||
const {
|
||||
title = '', chartType = 'bar', theme = 'default',
|
||||
showLegend = true, showGrid = true, showLabel = false,
|
||||
stackMode = false, smoothLine = true
|
||||
} = cfg;
|
||||
|
||||
const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff';
|
||||
const textColor = theme === 'dark' ? '#e0e0e0' : '#333333';
|
||||
const axisLineColor = theme === 'dark' ? '#444' : '#ddd';
|
||||
const palette = colorPalettes[theme] || colorPalettes.default;
|
||||
|
||||
const series = parsed.seriesNames.map((name, idx) => {
|
||||
const color = palette[idx % palette.length];
|
||||
let type = chartType === 'bar-line' ? (idx % 2 === 0 ? 'bar' : 'line') : chartType;
|
||||
|
||||
const s = {
|
||||
name,
|
||||
type,
|
||||
data: [...parsed.seriesData[name]],
|
||||
itemStyle: { color },
|
||||
emphasis: { focus: 'series' }
|
||||
};
|
||||
|
||||
if (stackMode) s.stack = 'total';
|
||||
|
||||
if (type === 'line') {
|
||||
s.smooth = smoothLine;
|
||||
s.lineStyle = { width: 3 };
|
||||
s.symbolSize = 8;
|
||||
s.areaStyle = theme === 'gradient' ? { opacity: 0.15 } : undefined;
|
||||
}
|
||||
|
||||
if (type === 'bar') {
|
||||
s.barMaxWidth = 40;
|
||||
s.itemStyle.borderRadius = stackMode ? [0, 0, 0, 0] : [4, 4, 0, 0];
|
||||
if (theme === 'gradient') {
|
||||
s.itemStyle.color = new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color },
|
||||
{ offset: 1, color: adjustColor(color, 40) }
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (showLabel) {
|
||||
s.label = {
|
||||
show: true,
|
||||
position: 'top',
|
||||
fontSize: 11,
|
||||
color: textColor,
|
||||
formatter: (p) => {
|
||||
if (p.value >= 10000) return (p.value / 10000).toFixed(1) + 'w';
|
||||
if (p.value >= 1000) return (p.value / 1000).toFixed(1) + 'k';
|
||||
return p.value;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return s;
|
||||
});
|
||||
|
||||
return {
|
||||
backgroundColor: bgColor,
|
||||
title: title ? {
|
||||
text: title,
|
||||
left: 'center',
|
||||
top: 10,
|
||||
textStyle: { color: textColor, fontSize: 16, fontWeight: 600 }
|
||||
} : undefined,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: theme === 'dark' ? 'rgba(30,30,50,0.95)' : 'rgba(255,255,255,0.95)',
|
||||
borderColor: theme === 'dark' ? '#555' : '#eee',
|
||||
textStyle: { color: textColor }
|
||||
},
|
||||
legend: showLegend ? {
|
||||
show: true,
|
||||
top: title ? 40 : 10,
|
||||
textStyle: { color: textColor, fontSize: 12 },
|
||||
itemGap: 20,
|
||||
icon: 'roundRect'
|
||||
} : { show: false },
|
||||
grid: {
|
||||
left: '3%', right: '4%', bottom: '3%',
|
||||
top: showLegend ? (title ? 70 : 45) : (title ? 50 : 30),
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: parsed.categories,
|
||||
axisLine: { lineStyle: { color: axisLineColor } },
|
||||
axisLabel: { color: textColor, fontSize: 11, interval: 0, rotate: parsed.categories.length > 10 ? 30 : 0 },
|
||||
axisTick: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: textColor, fontSize: 11 },
|
||||
splitLine: { show: showGrid, lineStyle: { color: theme === 'dark' ? '#333' : '#f0f0f0', type: 'dashed' } }
|
||||
},
|
||||
series,
|
||||
animation: false
|
||||
};
|
||||
}
|
||||
|
||||
// 根据数据量估算子图尺寸
|
||||
function measureCombineChart(dataText) {
|
||||
const p = parseData(dataText);
|
||||
if (!p) return { w: 600, h: 400 };
|
||||
const w = Math.min(900, Math.max(500, p.categories.length * 70 + 140));
|
||||
return { w, h: 400 };
|
||||
}
|
||||
|
||||
// 生成合并图
|
||||
function generateCombine() {
|
||||
const d1 = document.getElementById('combineData1').value.trim();
|
||||
const d2 = document.getElementById('combineData2').value.trim();
|
||||
if (!d1 || !d2) return;
|
||||
|
||||
const direction = document.querySelector('input[name="combineDirection"]:checked').value;
|
||||
const gap = 24;
|
||||
|
||||
const readCfg = (n) => ({
|
||||
title: document.getElementById('combineTitle' + n).value,
|
||||
chartType: document.getElementById('combineType' + n).value,
|
||||
theme: document.getElementById('combineTheme' + n).value,
|
||||
showLegend: document.getElementById('combineLegend' + n).checked,
|
||||
showGrid: document.getElementById('combineGrid' + n).checked,
|
||||
showLabel: document.getElementById('combineLabel' + n).checked,
|
||||
stackMode: document.getElementById('combineStack' + n).checked
|
||||
});
|
||||
|
||||
const chartArea = document.getElementById('chartArea');
|
||||
chartArea.innerHTML = '<div class="placeholder"><p>⏳</p><p>正在合并...</p></div>';
|
||||
|
||||
const renderOne = (dataText, cfg, w, h) => new Promise((resolve, reject) => {
|
||||
const option = buildCombineChartOption(dataText, cfg);
|
||||
if (!option) { reject(new Error('数据格式错误')); return; }
|
||||
const holder = document.createElement('div');
|
||||
holder.style.cssText = `position:absolute;left:-9999px;top:0;width:${w}px;height:${h}px;`;
|
||||
document.body.appendChild(holder);
|
||||
const chart = echarts.init(holder, null, { renderer: 'canvas' });
|
||||
chart.setOption(option);
|
||||
setTimeout(() => {
|
||||
const cvs = holder.querySelector('canvas');
|
||||
if (!cvs) { reject(new Error('子图渲染失败')); return; }
|
||||
resolve(cvs);
|
||||
chart.dispose();
|
||||
holder.remove();
|
||||
}, 80);
|
||||
});
|
||||
|
||||
const m1 = measureCombineChart(d1);
|
||||
const m2 = measureCombineChart(d2);
|
||||
|
||||
Promise.all([
|
||||
renderOne(d1, readCfg(1), m1.w, m1.h),
|
||||
renderOne(d2, readCfg(2), m2.w, m2.h)
|
||||
]).then(([c1, c2]) => {
|
||||
let W, H, r1, r2;
|
||||
if (direction === 'vertical') {
|
||||
// 竖排(上下):等宽
|
||||
const tw = Math.max(c1.width, c2.width);
|
||||
r1 = { w: tw, h: Math.round(c1.height * tw / c1.width) };
|
||||
r2 = { w: tw, h: Math.round(c2.height * tw / c2.width) };
|
||||
W = tw; H = r1.h + r2.h + gap;
|
||||
} else {
|
||||
// 横排(左右):等高
|
||||
const th = Math.max(c1.height, c2.height);
|
||||
r1 = { w: Math.round(c1.width * th / c1.height), h: th };
|
||||
r2 = { w: Math.round(c2.width * th / c2.height), h: th };
|
||||
W = r1.w + r2.w + gap; H = th;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
if (direction === 'vertical') {
|
||||
ctx.drawImage(c1, 0, 0, r1.w, r1.h);
|
||||
ctx.drawImage(c2, 0, r1.h + gap, r2.w, r2.h);
|
||||
} else {
|
||||
ctx.drawImage(c1, 0, 0, r1.w, r1.h);
|
||||
ctx.drawImage(c2, r1.w + gap, 0, r2.w, r2.h);
|
||||
}
|
||||
|
||||
combinedCanvas = canvas;
|
||||
const url = canvas.toDataURL('image/png');
|
||||
chartArea.innerHTML = `<div class="table-preview"><img src="${url}" alt="合并图预览" style="max-width:100%;"></div>`;
|
||||
}).catch(err => {
|
||||
chartArea.innerHTML = `<div class="placeholder"><p>❌</p><p>${err.message}</p></div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// 导出合并图
|
||||
function exportCombine() {
|
||||
if (!combinedCanvas) {
|
||||
alert('请先生成合并图');
|
||||
return;
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = combinedCanvas.toDataURL('image/png');
|
||||
a.download = 'combine.png';
|
||||
a.click();
|
||||
}
|
||||
Reference in New Issue
Block a user