feat: 双图合并升级为多图合并 + 数据输入框加宽
- 合并模式默认2张图,新增'+添加图表'按钮可不断添加,每张图可独立删除 - 每张图卡片:标题/类型/主题/显示选项/数据,动态渲染(combineCharts 数组驱动) - 合并逻辑支持 N 张图:横排等高依次排列、竖排等宽依次堆叠 - 后端 /api/combine 支持 charts 数组(N 张),兼容 chart1+chart2 - 修复合并图数据输入框过窄:.config-group textarea 统一 width:100%(与设置区一致)
This commit is contained in:
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user