Files
data-chart-tool/app.js
T
hz4th_coder e62aac539b feat: 新增双图合并模式,支持横排/竖排将两张图合并为一张图
- 后端新增 POST /api/combine 接口(chart1/chart2 + direction/gap/pixelRatio/background)
- 前端新增 🖼️ 双图合并模式:两张图独立配置数据/标题/类型/主题
- 横排(左右等高三张图)或竖排(上下等宽),一键切换实时预览
- 修复 @napi-rs/canvas echarts 渲染需先 toBuffer 再 dispose 的关键问题
- 更新 README/API 文档
2026-08-19 12:15:31 +08:00

1008 lines
35 KiB
JavaScript

// ===== 全局状态 =====
let chartInstance = null;
let parsedData = null;
let seriesColors = [];
let seriesOrder = [];
let currentMode = 'chart'; // 'chart' or 'table'
let tableImageBlob = null;
// ===== 预设颜色方案 =====
const colorPalettes = {
default: ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc'],
dark: ['#4fc3f7', '#81c784', '#fff176', '#ff8a65', '#ba68c8', '#4dd0e1', '#ffab91', '#aed581', '#f48fb1'],
macarons: ['#2ec7c9', '#b6a2de', '#5ab1ef', '#ffb980', '#d87a80', '#8d98b3', '#e5cf0d', '#97b552', '#95706d'],
gradient: ['#7f7fd5', '#86a8e7', '#91eae4', '#ff6b6b', '#feca57', '#48dbfb', '#ff9ff3', '#54a0ff', '#5f27cd'],
retro: ['#d4a5a5', '#95b9c7', '#f6e8c3', '#dfceb4', '#a4c3b5', '#c9b1ff', '#f5c7b8', '#b8d4e3', '#e8c8a0'],
ocean: ['#0077b6', '#00b4d8', '#90e0ef', '#caf0f8', '#023e8a', '#48cae4', '#ade8f4', '#0096c7', '#03045e'],
forest: ['#2d6a4f', '#40916c', '#52b788', '#74c69d', '#95d5b2', '#b7e4c7', '#d8f3dc', '#1b4332', '#344e41'],
sunset: ['#e85d04', '#f48c06', '#faa307', '#ffba08', '#dc2f02', '#d00000', '#9d0208', '#6a040f', '#370617'],
lavender: ['#7c3aed', '#8b5cf6', '#a78bfa', '#c4b5fd', '#ddd6fe', '#ede9fe', '#5b21b6', '#4c1d95', '#6d28d9'],
minimal: ['#111827', '#374151', '#6b7280', '#9ca3af', '#d1d5db', '#e5e7eb', '#f3f4f6', '#4b5563', '#1f2937'],
cherry: ['#e91e63', '#f06292', '#f48fb1', '#f8bbd0', '#fce4ec', '#c2185b', '#ad1457', '#880e4f', '#d81b60'],
midnight: ['#6c63ff', '#5a52d5', '#483fb5', '#7c79ff', '#3d3d5c', '#8b89ff', '#9d9bff', '#2c2c4a', '#b3b2ff'],
gold: ['#b8860b', '#daa520', '#f0c040', '#ffd700', '#ffe066', '#996515', '#cc9900', '#e6b800', '#8b6914'],
coral: ['#ff6f61', '#ff8a70', '#ffa07a', '#ffab91', '#ffccbc', '#e64a19', '#f4511e', '#ff5722', '#d84315'],
mint: ['#00bfa5', '#26a69a', '#4db6ac', '#80cbc4', '#b2dfdb', '#00897b', '#00796b', '#004d40', '#009688']
};
// ===== 示例数据 =====
const sampleDataSets = [
{
name: '季度销售对比',
data: `产品, Q1, Q2, Q3, Q4
手机, 1200, 1800, 2100, 2500
平板, 800, 950, 1100, 1300
笔记本, 600, 750, 900, 1050
耳机, 400, 520, 680, 800`
},
{
name: '年度增长趋势',
data: `指标, 2020年, 2021年, 2022年, 2023年, 2024年
营收(万), 500, 680, 820, 1050, 1380
利润(万), 80, 120, 160, 230, 350
用户(千), 50, 85, 130, 200, 320`
},
{
name: '多指标对比(适合区域分割)',
data: `月份, 方案A-效率, 方案A-成本, 方案B-效率, 方案B-成本
1月, 85, 120, 78, 135
2月, 88, 115, 82, 128
3月, 92, 108, 88, 120
4月, 90, 112, 95, 110
5月, 95, 105, 98, 105
6月, 98, 98, 102, 95`
}
];
// ===== 初始化 =====
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();
});
// ===== 数据解析 =====
function parseData(rawText) {
const lines = rawText.trim().split('\n').filter(l => l.trim());
if (lines.length < 2) {
alert('数据至少需要包含表头和一行数据');
return null;
}
// 自动检测分隔符
let delimiter = ',';
if (lines[0].includes('\t')) {
delimiter = '\t';
} else if (lines[0].split('|').length > lines[0].split(',').length) {
delimiter = '|';
}
const rows = lines.map(line => {
return line.split(delimiter).map(cell => cell.trim());
});
const headers = rows[0];
const categories = [];
const seriesData = {};
// 第一列是横坐标,其余列是系列
for (let i = 1; i < headers.length; i++) {
seriesData[headers[i]] = [];
}
for (let i = 1; i < rows.length; i++) {
categories.push(rows[i][0]);
for (let j = 1; j < rows[i].length && j < headers.length; j++) {
const val = parseFloat(rows[i][j]);
seriesData[headers[j]].push(isNaN(val) ? 0 : val);
}
}
return {
categories,
seriesNames: headers.slice(1),
seriesData
};
}
// ===== 生成图表 =====
function generateChart() {
const rawText = document.getElementById('dataInput').value;
if (!rawText.trim()) {
alert('请输入数据');
return;
}
parsedData = parseData(rawText);
if (!parsedData) return;
// 初始化系列顺序和颜色
seriesOrder = parsedData.seriesNames.map((_, i) => i);
const palette = colorPalettes[document.getElementById('themeStyle').value] || colorPalettes.default;
seriesColors = parsedData.seriesNames.map((_, i) => palette[i % palette.length]);
// 渲染系列配置
renderSeriesConfig();
// 初始化图表
initChart();
updateChart();
}
// ===== 初始化图表实例 =====
function initChart() {
const chartDom = document.getElementById('chartArea');
chartDom.innerHTML = '';
if (chartInstance) {
chartInstance.dispose();
}
chartInstance = echarts.init(chartDom, null, { renderer: 'canvas' });
// 响应式
window.addEventListener('resize', () => {
chartInstance && chartInstance.resize();
});
}
// ===== 更新图表 =====
function updateChart() {
if (!parsedData || !chartInstance) return;
const chartType = document.getElementById('chartType').value;
const title = document.getElementById('chartTitle').value;
const theme = document.getElementById('themeStyle').value;
const showLegend = document.getElementById('showLegend').checked;
const showGrid = document.getElementById('showGrid').checked;
const showLabel = document.getElementById('showLabel').checked;
const stackMode = document.getElementById('stackMode').checked;
const smoothLine = document.getElementById('smoothLine').checked;
const enableSplit = document.getElementById('enableSplit').checked;
// 显示/隐藏分割配置
document.getElementById('splitConfig').style.display = enableSplit ? 'block' : 'none';
// 背景色
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 = seriesOrder.map((origIdx, displayIdx) => {
const name = parsedData.seriesNames[origIdx];
const data = parsedData.seriesData[name];
const color = seriesColors[origIdx] || palette[origIdx % palette.length];
let type = chartType === 'bar-line'
? (displayIdx % 2 === 0 ? 'bar' : 'line')
: chartType;
const seriesItem = {
name: name,
type: type,
data: [...data],
itemStyle: { color: color },
emphasis: {
focus: 'series',
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(0,0,0,0.3)'
}
}
};
// 堆叠
if (stackMode) {
seriesItem.stack = 'total';
}
// 折线图特有
if (type === 'line') {
seriesItem.smooth = smoothLine;
seriesItem.lineStyle = { width: 3 };
seriesItem.symbolSize = 8;
seriesItem.areaStyle = theme === 'gradient' ? {
opacity: 0.15
} : undefined;
}
// 柱状图特有
if (type === 'bar') {
seriesItem.barMaxWidth = 40;
seriesItem.itemStyle.borderRadius = stackMode ? [0, 0, 0, 0] : [4, 4, 0, 0];
if (theme === 'gradient') {
seriesItem.itemStyle.color = new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: color },
{ offset: 1, color: adjustColor(color, 40) }
]);
}
}
// 数据标签
if (showLabel) {
seriesItem.label = {
show: true,
position: type === 'bar' ? 'top' : 'top',
fontSize: 11,
color: textColor,
formatter: (params) => {
if (params.value >= 10000) return (params.value / 10000).toFixed(1) + 'w';
if (params.value >= 1000) return (params.value / 1000).toFixed(1) + 'k';
return params.value;
}
};
}
return seriesItem;
});
// 区域分割 - 使用 graphic 组件精确画分割线在柱子间隙中
const graphicElements = [];
if (enableSplit) {
const splitIdx = parseInt(document.getElementById('splitIndex').value) || 3;
const leftLabel = document.getElementById('leftLabel').value || '左侧';
const rightLabel = document.getElementById('rightLabel').value || '右侧';
const splitStyle = document.getElementById('splitStyle').value;
if (splitIdx < parsedData.categories.length) {
// 区域背景色用 markArea
const splitLineColor = theme === 'dark' ? '#ff6b6b' : '#e74c3c';
if (series.length > 0) {
series[0].markArea = {
silent: true,
data: [
[
{
name: leftLabel,
xAxis: parsedData.categories[0],
itemStyle: {
color: theme === 'dark' ? 'rgba(79,195,247,0.06)' : 'rgba(79,70,229,0.05)',
borderWidth: 0
},
label: {
show: true,
position: 'insideTop',
fontSize: 13,
fontWeight: 'bold',
color: theme === 'dark' ? '#4fc3f7' : '#4f46e5',
offset: [0, 10]
}
},
{ xAxis: parsedData.categories[splitIdx - 1] }
],
[
{
name: rightLabel,
xAxis: parsedData.categories[splitIdx],
itemStyle: {
color: theme === 'dark' ? 'rgba(255,107,107,0.06)' : 'rgba(239,68,68,0.05)',
borderWidth: 0
},
label: {
show: true,
position: 'insideTop',
fontSize: 13,
fontWeight: 'bold',
color: theme === 'dark' ? '#ff6b6b' : '#ef4444',
offset: [0, 10]
}
},
{ xAxis: parsedData.categories[parsedData.categories.length - 1] }
]
]
};
}
// 用 graphic 组件在渲染后画分割线(在柱子间隙中)
// 先存到全局,在 setOption 后通过 rendered 事件绘制
window._splitConfig = { splitIdx, splitLineColor, splitStyle, theme };
} else {
window._splitConfig = null;
}
} else {
window._splitConfig = null;
}
// 图表配置
const option = {
backgroundColor: bgColor,
title: title ? {
text: title,
left: 'center',
top: 15,
textStyle: {
color: textColor,
fontSize: 18,
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 },
axisPointer: {
type: chartType === 'bar' ? 'shadow' : 'cross',
crossStyle: { color: '#999' }
},
formatter: function(params) {
let html = `<div style="font-weight:600;margin-bottom:6px">${params[0].axisValue}</div>`;
params.forEach(p => {
html += `<div style="display:flex;align-items:center;gap:6px;margin:3px 0">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${p.color}"></span>
<span>${p.seriesName}:</span>
<span style="font-weight:600">${p.value.toLocaleString()}</span>
</div>`;
});
return html;
}
},
legend: showLegend ? {
show: true,
top: title ? 50 : 15,
textStyle: { color: textColor, fontSize: 12 },
itemGap: 20,
icon: 'roundRect'
} : { show: false },
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: showLegend ? (title ? 90 : 60) : (title ? 60 : 30),
containLabel: true
},
xAxis: {
type: 'category',
data: parsedData.categories,
axisLine: { lineStyle: { color: axisLineColor } },
axisLabel: {
color: textColor,
fontSize: 12,
interval: 0,
rotate: parsedData.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: series,
animationDuration: 800,
animationEasing: 'cubicOut'
};
chartInstance.setOption(option, true);
// 绘制分割线(在 category 间隙中)
drawSplitLine();
}
// ===== 在柱子间隙中精确绘制分割线 =====
function drawSplitLine() {
if (!chartInstance || !window._splitConfig) {
if (chartInstance) {
chartInstance.setOption({ graphic: { elements: [] } });
}
return;
}
const { splitIdx, splitLineColor, splitStyle } = window._splitConfig;
// 使用 convertToPixel 获取 category 的像素坐标(x位置)
const leftX = chartInstance.convertToPixel({ xAxisIndex: 0 }, splitIdx - 1);
const rightX = chartInstance.convertToPixel({ xAxisIndex: 0 }, splitIdx);
const midX = (leftX + rightX) / 2;
// 使用内部 coordinateSystem 获取精确的绘图区域边界
// 这是ECharts内部稳定可用的接口
const gridRect = chartInstance.getModel()
.getComponent('grid', 0)
.coordinateSystem.getRect();
const yTop = gridRect.y;
const yBottom = gridRect.y + gridRect.height;
// 虚线样式
let lineDash = null;
if (splitStyle === 'dashed') lineDash = [8, 4];
else if (splitStyle === 'dotted') lineDash = [3, 3];
chartInstance.setOption({
graphic: {
elements: [
{
type: 'line',
shape: {
x1: midX,
y1: yTop,
x2: midX,
y2: yBottom
},
style: {
stroke: splitLineColor,
lineWidth: 2,
lineDash: lineDash
},
z: 100
}
]
}
});
}
// ===== 渲染系列配置 =====
function renderSeriesConfig() {
const container = document.getElementById('seriesConfig');
container.innerHTML = '';
seriesOrder.forEach((origIdx, displayIdx) => {
const name = parsedData.seriesNames[origIdx];
const color = seriesColors[origIdx];
const item = document.createElement('div');
item.className = 'series-item';
item.draggable = true;
item.dataset.index = displayIdx;
item.innerHTML = `
<span class="drag-handle">⠿</span>
<span class="series-name">${name}</span>
<input type="color" value="${color}" onchange="updateSeriesColor(${origIdx}, this.value)">
<select onchange="updateSeriesType(${origIdx}, this.value)">
<option value="auto">自动</option>
<option value="bar">柱状</option>
<option value="line">折线</option>
</select>
`;
// 拖拽事件
item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragover', handleDragOver);
item.addEventListener('drop', handleDrop);
item.addEventListener('dragend', handleDragEnd);
container.appendChild(item);
});
}
// ===== 拖拽排序 =====
let draggedItem = null;
function handleDragStart(e) {
draggedItem = this;
this.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = this.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
this.style.borderTopColor = 'var(--primary)';
this.style.borderTopWidth = '2px';
} else {
this.style.borderBottomColor = 'var(--primary)';
this.style.borderBottomWidth = '2px';
}
}
function handleDrop(e) {
e.preventDefault();
// 清除样式
document.querySelectorAll('.series-item').forEach(item => {
item.style.borderTopColor = '';
item.style.borderTopWidth = '';
item.style.borderBottomColor = '';
item.style.borderBottomWidth = '';
});
if (draggedItem === this) return;
const fromIdx = parseInt(draggedItem.dataset.index);
const toIdx = parseInt(this.dataset.index);
// 交换顺序
const temp = seriesOrder[fromIdx];
seriesOrder[fromIdx] = seriesOrder[toIdx];
seriesOrder[toIdx] = temp;
renderSeriesConfig();
updateChart();
}
function handleDragEnd() {
this.classList.remove('dragging');
document.querySelectorAll('.series-item').forEach(item => {
item.style.borderTopColor = '';
item.style.borderTopWidth = '';
item.style.borderBottomColor = '';
item.style.borderBottomWidth = '';
});
}
// ===== 系列操作 =====
function updateSeriesColor(origIdx, color) {
seriesColors[origIdx] = color;
updateChart();
}
function updateSeriesType(origIdx, type) {
// 存储自定义类型覆盖
if (!window.seriesTypeOverrides) window.seriesTypeOverrides = {};
window.seriesTypeOverrides[origIdx] = type;
updateChart();
}
// ===== 工具函数 =====
function adjustColor(hex, amount) {
hex = hex.replace('#', '');
const num = parseInt(hex, 16);
let r = Math.min(255, ((num >> 16) & 0xff) + amount);
let g = Math.min(255, ((num >> 8) & 0xff) + amount);
let b = Math.min(255, (num & 0xff) + amount);
return `rgb(${r},${g},${b})`;
}
function loadSampleData() {
// 循环切换示例
const currentIdx = sampleDataSets.findIndex(s => s.data === document.getElementById('dataInput').value);
const nextIdx = (currentIdx + 1) % sampleDataSets.length;
document.getElementById('dataInput').value = sampleDataSets[nextIdx].data;
generateChart();
}
function clearData() {
document.getElementById('dataInput').value = '';
document.getElementById('chartArea').innerHTML = `
<div class="placeholder">
<p>📊</p>
<p>输入数据后点击"生成图表"</p>
</div>
`;
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
parsedData = null;
document.getElementById('seriesConfig').innerHTML = '<p class="hint-text">生成图表后可在此调整各系列的顺序和颜色</p>';
}
// ===== 导出图表 =====
function exportChart(format) {
if (!chartInstance) {
alert('请先生成图表');
return;
}
if (format === 'png') {
const url = chartInstance.getDataURL({
type: 'png',
pixelRatio: 2,
backgroundColor: document.getElementById('themeStyle').value === 'dark' ? '#1a1a2e' : '#fff'
});
const a = document.createElement('a');
a.href = url;
a.download = 'chart.png';
a.click();
} else if (format === 'svg') {
// 重新用 SVG 渲染
const svgChart = echarts.init(document.createElement('div'), null, { renderer: 'svg' });
svgChart.setOption(chartInstance.getOption());
const url = svgChart.getDataURL({
type: 'svg',
pixelRatio: 2
});
const a = document.createElement('a');
a.href = url;
a.download = 'chart.svg';
a.click();
svgChart.dispose();
}
}
// ===== 模式切换 =====
function switchMode(mode) {
currentMode = 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');
if (mode === 'combine') {
btnGenerate.style.display = 'none'; // 双图模式用自己的按钮
} else {
btnGenerate.style.display = '';
btnGenerate.textContent = mode === 'chart' ? '🎨 生成图表' : '📋 生成表格';
}
// 更新导出按钮
const btnExportSvg = document.getElementById('btnExportSvg');
btnExportSvg.style.display = mode === 'chart' ? 'inline-flex' : 'none';
// 重新生成
generate();
}
// ===== 统一生成入口 =====
function generate() {
if (currentMode === 'chart') {
generateChart();
} else if (currentMode === 'table') {
generateTable();
} else {
generateCombine();
}
}
// ===== 更新字体大小显示 =====
function updateFontSize() {
const val = document.getElementById('tableFontSize').value;
document.getElementById('fontSizeValue').textContent = val;
}
// ===== 生成表格 =====
function generateTable() {
const rawText = document.getElementById('dataInput').value;
if (!rawText.trim()) {
alert('请输入数据');
return;
}
const params = {
data: rawText,
title: document.getElementById('tableTitle').value,
theme: document.getElementById('tableTheme').value,
fontSize: parseInt(document.getElementById('tableFontSize').value) || 14,
stripeRows: document.getElementById('stripeRows').checked,
borderStyle: document.getElementById('borderStyle').value,
pixelRatio: 2
};
const chartArea = document.getElementById('chartArea');
chartArea.innerHTML = '<div class="placeholder"><p>⏳</p><p>正在生成表格...</p></div>';
fetch('/api/table', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
})
.then(res => {
if (!res.ok) throw new Error('表格生成失败');
return res.blob();
})
.then(blob => {
tableImageBlob = blob;
const url = URL.createObjectURL(blob);
chartArea.innerHTML = `<div class="table-preview"><img src="${url}" alt="表格预览"></div>`;
})
.catch(err => {
chartArea.innerHTML = `<div class="placeholder"><p>❌</p><p>${err.message}</p></div>`;
});
}
// ===== 更新表格(配置变更时) =====
function updateTable() {
if (currentMode === 'table' && document.getElementById('dataInput').value.trim()) {
generateTable();
}
}
// ===== 统一导出图片 =====
function exportImage(format) {
if (currentMode === 'chart') {
exportChart(format);
} else if (currentMode === 'table') {
exportTable(format);
} else {
exportCombine();
}
}
// ===== 导出表格 =====
function exportTable(format) {
if (!tableImageBlob) {
alert('请先生成表格');
return;
}
const url = URL.createObjectURL(tableImageBlob);
const a = document.createElement('a');
a.href = url;
a.download = 'table.png';
a.click();
URL.revokeObjectURL(url);
}
// ===== 快速切换风格 =====
function quickSwitchTheme(theme) {
// 更新按钮状态
document.querySelectorAll('.theme-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === theme);
});
if (currentMode === 'chart') {
// 图表模式:更新主题选择器并重新渲染
document.getElementById('themeStyle').value = theme;
updateChart();
} else if (currentMode === 'table') {
// 表格模式:更新主题选择器并重新请求
document.getElementById('tableTheme').value = theme;
updateTable();
}
// 双图合并模式:仅切换按钮高亮,不联动(两图各自独立主题)
}
// ===== 同步风格按钮状态(下拉框变更时) =====
function syncThemeButtons(theme) {
document.querySelectorAll('.theme-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === theme);
});
}
// ===== 初始化风格按钮状态 =====
function initThemeButtons() {
// 默认激活 default
document.querySelectorAll('.theme-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === 'default');
});
}
// 页面加载时初始化
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();
}