feat: 双图合并升级为多图合并 + 数据输入框加宽

- 合并模式默认2张图,新增'+添加图表'按钮可不断添加,每张图可独立删除
- 每张图卡片:标题/类型/主题/显示选项/数据,动态渲染(combineCharts 数组驱动)
- 合并逻辑支持 N 张图:横排等高依次排列、竖排等宽依次堆叠
- 后端 /api/combine 支持 charts 数组(N 张),兼容 chart1+chart2
- 修复合并图数据输入框过窄:.config-group textarea 统一 width:100%(与设置区一致)
This commit is contained in:
2026-08-19 13:30:35 +08:00
parent 576024af76
commit e56c6cb79f
6 changed files with 219 additions and 203 deletions
+115 -48
View File
@@ -61,12 +61,8 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('dataInput').value = sampleDataSets[0].data;
generateChart();
// 预填双图合并示例不提前生成,切到双图模式时由 switchMode 触发)
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 = '年度增长趋势';
// 初始化多图合并(预填示例不提前生成,切到模式时由 switchMode 触发)
initCombineCharts();
// 初始化导出设置
renderHistory();
@@ -1164,30 +1160,106 @@ function measureCombineChart(dataText) {
return { w, h: 400 };
}
// 生成合并图
// ===== 多图合并管理 =====
let combineCharts = [];
function defaultCombineChart() {
return { title: '', type: 'bar', theme: 'default', legend: true, grid: true, label: false, stack: false, data: '' };
}
function initCombineCharts() {
combineCharts = [defaultCombineChart(), defaultCombineChart()];
combineCharts[0].data = sampleDataSets[0].data;
combineCharts[0].title = '季度销售对比';
combineCharts[1].data = sampleDataSets[1].data;
combineCharts[1].type = 'line';
combineCharts[1].title = '年度增长趋势';
renderCombineCharts();
}
const COMBINE_TYPES = [['bar', '柱状图'], ['line', '折线图'], ['bar-line', '柱状图+折线图混合']];
const COMBINE_THEMES = [['default', '默认'], ['dark', '深色'], ['macarons', '马卡龙'], ['gradient', '渐变'], ['retro', '复古'], ['ocean', '海洋'], ['forest', '森林'], ['sunset', '日落'], ['lavender', '薰衣草'], ['minimal', '极简'], ['cherry', '樱花'], ['midnight', '午夜'], ['gold', '金色'], ['coral', '珊瑚'], ['mint', '薄荷'], ['slate', '石板灰'], ['sky', '天空蓝'], ['rose', '玫瑰红'], ['amber', '琥珀黄'], ['emerald', '翡翠绿'], ['indigo', '靛蓝色'], ['stone', '石灰白']];
function escHtml(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function renderCombineCharts() {
const container = document.getElementById('combineChartsContainer');
if (!container) return;
container.innerHTML = combineCharts.map((c, i) => `
<div class="combine-chart-card" id="combineCard${i}">
<div class="combine-card-head">
<h3>📈 图${i + 1}</h3>
<button class="btn btn-remove" onclick="removeCombineChart(${i})" title="删除此图">✕</button>
</div>
<div class="config-group">
<label>标题</label>
<input type="text" value="${escHtml(c.title)}" placeholder="图${i + 1}标题" oninput="updateCombineChart(${i},'title',this.value)">
</div>
<div class="config-group">
<label>图表类型</label>
<select onchange="updateCombineChart(${i},'type',this.value)">
${COMBINE_TYPES.map(([v, l]) => `<option value="${v}" ${c.type === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
</div>
<div class="config-group">
<label>主题风格</label>
<select onchange="updateCombineChart(${i},'theme',this.value)">
${COMBINE_THEMES.map(([v, l]) => `<option value="${v}" ${c.theme === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
</div>
<div class="config-group">
<label>显示选项</label>
<div class="checkbox-group">
<label><input type="checkbox" ${c.legend ? 'checked' : ''} onchange="updateCombineChart(${i},'legend',this.checked)"> 图例</label>
<label><input type="checkbox" ${c.grid ? 'checked' : ''} onchange="updateCombineChart(${i},'grid',this.checked)"> 网格线</label>
<label><input type="checkbox" ${c.label ? 'checked' : ''} onchange="updateCombineChart(${i},'label',this.checked)"> 数据标签</label>
<label><input type="checkbox" ${c.stack ? 'checked' : ''} onchange="updateCombineChart(${i},'stack',this.checked)"> 堆叠</label>
</div>
</div>
<div class="config-group">
<label>数据</label>
<textarea rows="5" placeholder="图${i + 1}的CSV数据..." oninput="updateCombineChart(${i},'data',this.value)">${escHtml(c.data)}</textarea>
</div>
</div>
`).join('');
}
function updateCombineChart(i, field, value) {
if (!combineCharts[i]) return;
combineCharts[i][field] = value;
generateCombine();
}
function addCombineChart() {
combineCharts.push(defaultCombineChart());
renderCombineCharts();
generateCombine();
}
function removeCombineChart(i) {
if (combineCharts.length <= 1) { alert('至少保留一张图'); return; }
combineCharts.splice(i, 1);
renderCombineCharts();
generateCombine();
}
// 生成合并图(多图)
function generateCombine() {
const d1 = document.getElementById('combineData1').value.trim();
const d2 = document.getElementById('combineData2').value.trim();
if (!d1 || !d2) return;
const validCharts = combineCharts.filter(c => c.data && c.data.trim());
if (validCharts.length === 0) 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);
const renderOne = (c, w, h) => new Promise((resolve, reject) => {
const option = buildCombineChartOption(c.data, {
title: c.title, chartType: c.type, theme: c.theme,
showLegend: c.legend, showGrid: c.grid, showLabel: c.label, stackMode: c.stack
});
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;`;
@@ -1197,9 +1269,7 @@ function generateCombine() {
setTimeout(() => {
const srcCvs = holder.querySelector('canvas');
if (!srcCvs) { reject(new Error('子图渲染失败')); return; }
// 关键:先把内容复制到独立 canvas 再 dispose
// 直接 resolve 原 canvas 的话,Promise 微任务在 chart.dispose() 之后才执行,
// 此时 canvas 内容已被 echarts 清空,合并出来就是白图。
// 关键:先把内容复制到独立 canvas 再 dispose,避免 dispose 清空内容
const copy = document.createElement('canvas');
copy.width = srcCvs.width;
copy.height = srcCvs.height;
@@ -1210,26 +1280,23 @@ function generateCombine() {
}, 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;
Promise.all(validCharts.map(c => {
const m = measureCombineChart(c.data);
return renderOne(c, m.w, m.h);
})).then(canvases => {
let W, H, scaled;
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;
// 竖排:等宽
const tw = Math.max(...canvases.map(c => c.width));
scaled = canvases.map(c => ({ c, w: tw, h: Math.round(c.height * tw / c.width) }));
W = tw;
H = scaled.reduce((s, x) => s + x.h, 0) + gap * (scaled.length - 1);
} 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 th = Math.max(...canvases.map(c => c.height));
scaled = canvases.map(c => ({ c, w: Math.round(c.width * th / c.height), h: th }));
W = scaled.reduce((s, x) => s + x.w, 0) + gap * (scaled.length - 1);
H = th;
}
const canvas = document.createElement('canvas');
@@ -1240,11 +1307,11 @@ function generateCombine() {
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);
let y = 0;
scaled.forEach(x => { ctx.drawImage(x.c, 0, y, x.w, x.h); y += x.h + gap; });
} else {
ctx.drawImage(c1, 0, 0, r1.w, r1.h);
ctx.drawImage(c2, r1.w + gap, 0, r2.w, r2.h);
let x = 0;
scaled.forEach(s => { ctx.drawImage(s.c, x, 0, s.w, s.h); x += s.w + gap; });
}
combinedCanvas = canvas;