// ===== 全局状态 ===== let chartInstance = null; let parsedData = null; let seriesColors = []; let seriesOrder = []; let seriesAxis = []; // 每个系列所属轴:0=左轴 1=右轴 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(); // 预填双图合并示例(不提前生成,切到双图模式时由 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 = '年度增长趋势'; // 初始化导出设置 renderHistory(); refreshExportHint(); }); // ===== 数据解析 ===== 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]); seriesAxis = parsedData.seriesNames.map(() => 0); // 渲染系列配置 renderSeriesConfig(); // 初始化图表 initChart(); updateChart(); refreshPreview(); } // ===== 初始化图表实例 ===== 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; const dualAxis = document.getElementById('dualAxis').checked; const rightAxisName = document.getElementById('rightAxisName').value; // 显示/隐藏分割配置 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; } }; } // 双Y轴:按系列指定左右轴 if (dualAxis) { seriesItem.yAxisIndex = (seriesAxis[origIdx] === 1) ? 1 : 0; } 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 = `
${params[0].axisValue}
`; params.forEach(p => { html += `
${p.seriesName}: ${p.value.toLocaleString()}
`; }); 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: dualAxis ? [ { type: 'value', name: '左轴', nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] }, axisLine: { show: false }, axisTick: { show: false }, axisLabel: { color: textColor, fontSize: 11 }, splitLine: { show: showGrid, lineStyle: { color: theme === 'dark' ? '#333' : '#f0f0f0', type: 'dashed' } } }, { type: 'value', name: rightAxisName || '右轴', position: 'right', nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 4, 0, 0] }, axisLine: { show: false }, axisTick: { show: false }, axisLabel: { color: textColor, fontSize: 11 }, splitLine: { show: false } } ] : { 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(); refreshPreview(); } // ===== 在柱子间隙中精确绘制分割线 ===== 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 dualAxis = document.getElementById('dualAxis').checked; const axisSelect = dualAxis ? ` ` : ''; const item = document.createElement('div'); item.className = 'series-item'; item.draggable = true; item.dataset.index = displayIdx; item.innerHTML = ` ${name} ${axisSelect} `; // 拖拽事件 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(); } // ===== 双Y轴 ===== function onDualAxisChange() { const on = document.getElementById('dualAxis').checked; document.getElementById('rightAxisGroup').style.display = on ? 'block' : 'none'; renderSeriesConfig(); // 显示/隐藏系列轴选择 updateChart(); } function updateSeriesAxis(origIdx, axis) { seriesAxis[origIdx] = parseInt(axis) || 0; 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 = `

📊

输入数据后点击"生成图表"

`; if (chartInstance) { chartInstance.dispose(); chartInstance = null; } parsedData = null; document.getElementById('seriesConfig').innerHTML = '

生成图表后可在此调整各系列的顺序和颜色

'; } // ===== 导出图表(SVG 专用) ===== function exportChartSvg() { if (!chartInstance) { alert('请先生成图表'); return; } 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 = buildExportFilename('chart', 'svg'); a.click(); svgChart.dispose(); } // ===== 导出设置(分辨率/历史/命名) ===== const EXPORT_PRESETS = { 'original': { label: '原始大小', w: 0, h: 0 }, '1920x1080': { label: '1920 × 1080', w: 1920, h: 1080 }, '1280x720': { label: '1280 × 720', w: 1280, h: 720 }, '1024x768': { label: '1024 × 768', w: 1024, h: 768 }, '800x600': { label: '800 × 600', w: 800, h: 600 }, 'custom': { label: '自定义', w: null, h: null } }; const HISTORY_KEY = 'dct_export_history'; const HISTORY_MAX = 5; // 当前生效的分辨率 {w, h}(0 表示原始大小) function getResolution() { const preset = document.getElementById('exportPreset').value; if (preset === 'original') return { w: 0, h: 0 }; if (preset === 'custom') { const w = parseInt(document.getElementById('customWidth').value) || 0; const h = parseInt(document.getElementById('customHeight').value) || 0; return { w, h }; } const p = EXPORT_PRESETS[preset]; return { w: p.w, h: p.h }; } function onExportPresetChange() { const preset = document.getElementById('exportPreset').value; document.getElementById('customResGroup').style.display = preset === 'custom' ? 'block' : 'none'; refreshExportHint(); refreshPreview(); } // 导出设置面板展开/折叠(默认折叠) let exportSettingsOpen = false; function toggleExportSettings() { exportSettingsOpen = !exportSettingsOpen; const body = document.getElementById('exportSettingsBody'); const t = document.getElementById('exportToggle'); body.style.display = exportSettingsOpen ? 'block' : 'none'; t.textContent = exportSettingsOpen ? '▾' : '▸'; } // 一键恢复默认(重置全部设置,历史记录保留) function resetExportSettings() { document.getElementById('exportPreset').value = 'original'; document.getElementById('customResGroup').style.display = 'none'; document.getElementById('customWidth').value = ''; document.getElementById('customHeight').value = ''; document.getElementById('exportFilename').value = ''; document.getElementById('exportHistory').value = ''; refreshExportHint(); refreshPreview(); } function onCustomResInput() { refreshExportHint(); refreshPreview(); const { w, h } = getResolution(); if (w > 0 && h > 0) saveHistory(w, h); } // 历史分辨率管理(最多 5 个,存 localStorage) function loadHistory() { try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; } catch (e) { return []; } } function saveHistory(w, h) { if (!w || !h) return; let hist = loadHistory(); const key = w + 'x' + h; hist = hist.filter(x => x.key !== key); hist.unshift({ key, w, h }); hist = hist.slice(0, HISTORY_MAX); localStorage.setItem(HISTORY_KEY, JSON.stringify(hist)); renderHistory(); } function renderHistory() { const sel = document.getElementById('exportHistory'); const hist = loadHistory(); const cur = sel.value; sel.innerHTML = hist.length ? hist.map(h => ``).join('') : ''; if (cur && hist.some(h => h.key === cur)) sel.value = cur; } function onHistorySelect() { const v = document.getElementById('exportHistory').value; if (!v) return; const [w, h] = v.split('x').map(Number); const preset = Object.entries(EXPORT_PRESETS).find(([k, p]) => p.w === w && p.h === h); document.getElementById('exportPreset').value = preset ? preset[0] : 'custom'; if (preset) { document.getElementById('customResGroup').style.display = 'none'; } else { document.getElementById('customResGroup').style.display = 'block'; document.getElementById('customWidth').value = w; document.getElementById('customHeight').value = h; } refreshExportHint(); refreshPreview(); } // 文件名:手动填写优先,否则按日期时间自动命名(每次不同) function buildExportFilename(prefix, ext) { ext = ext || 'png'; const manual = document.getElementById('exportFilename').value.trim(); if (manual) { return /\.[a-zA-Z0-9]+$/.test(manual) ? manual : manual + '.' + ext; } const d = new Date(); const pad = n => String(n).padStart(2, '0'); return `${prefix}_${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}.${ext}`; } // 把源图 contain 居中缩放到目标分辨率画布(自动适配深色背景) function applyResolution(srcCanvas, tw, th) { if (!tw || !th) return srcCanvas; let bg = '#ffffff'; try { const d = srcCanvas.getContext('2d').getImageData(0, 0, 1, 1).data; if (d[3] > 0 && (d[0] + d[1] + d[2]) / 3 < 100) bg = '#1a1a2e'; } catch (e) {} const c = document.createElement('canvas'); c.width = tw; c.height = th; const ctx = c.getContext('2d'); ctx.fillStyle = bg; ctx.fillRect(0, 0, tw, th); const scale = Math.min(tw / srcCanvas.width, th / srcCanvas.height); const dw = Math.round(srcCanvas.width * scale); const dh = Math.round(srcCanvas.height * scale); ctx.drawImage(srcCanvas, Math.round((tw - dw) / 2), Math.round((th - dh) / 2), dw, dh); return c; } // 获取当前模式源 canvas(异步) function getSourceCanvas() { return new Promise((resolve, reject) => { if (currentMode === 'chart') { if (!chartInstance) return reject(new Error('请先生成图表')); const cvs = chartInstance.getDom().querySelector('canvas'); if (!cvs) return reject(new Error('图表未渲染')); resolve(cvs); } else if (currentMode === 'table') { if (!tableImageBlob) return reject(new Error('请先生成表格')); const url = URL.createObjectURL(tableImageBlob); const img = new Image(); img.onload = () => { const c = document.createElement('canvas'); c.width = img.width; c.height = img.height; c.getContext('2d').drawImage(img, 0, 0); URL.revokeObjectURL(url); resolve(c); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('表格图片加载失败')); }; img.src = url; } else { if (!combinedCanvas) return reject(new Error('请先生成合并图')); resolve(combinedCanvas); } }); } // 生成最终导出 canvas(应用当前分辨率) function getExportCanvas() { return getSourceCanvas().then(src => { const { w, h } = getResolution(); return applyResolution(src, w, h); }); } // 统一下载图片 function exportCurrent(prefix) { getExportCanvas().then(canvas => { const a = document.createElement('a'); a.href = canvas.toDataURL('image/png'); a.download = buildExportFilename(prefix); a.click(); const { w, h } = getResolution(); if (w > 0 && h > 0) saveHistory(w, h); }).catch(err => alert(err.message)); } // 导出分辨率提示 + 折叠栏摘要 function refreshExportHint() { const { w, h } = getResolution(); const hint = document.getElementById('exportResHint'); if (hint) hint.textContent = w > 0 && h > 0 ? `导出分辨率:${w} × ${h}(原图居中缩放)` : '导出分辨率:原始大小'; const sum = document.getElementById('exportSummary'); if (sum) sum.textContent = w > 0 && h > 0 ? `${w} × ${h}` : '原始大小'; } // 刷新导出预览:以原图为中心,居中展示保存后的图 function refreshPreview() { const box = document.getElementById('exportPreviewBox'); const inner = document.getElementById('exportPreviewInner'); const title = document.getElementById('exportPreviewTitle'); if (!box || !inner) return; getExportCanvas().then(canvas => { const { w, h } = getResolution(); const url = canvas.toDataURL('image/png'); const sizeTag = w > 0 && h > 0 ? ` ${w} × ${h}` : ''; title.innerHTML = `📐 导出预览${sizeTag}`; inner.innerHTML = `导出预览`; box.style.display = 'block'; }).catch(() => { box.style.display = 'none'; }); } // ===== 模式切换 ===== 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 = '

正在生成表格...

'; 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 = `
表格预览
`; refreshPreview(); }) .catch(err => { chartArea.innerHTML = `

${err.message}

`; }); } // ===== 更新表格(配置变更时) ===== function updateTable() { if (currentMode === 'table' && document.getElementById('dataInput').value.trim()) { generateTable(); } } // ===== 统一导出图片 ===== function exportImage(format) { if (format === 'svg') { if (currentMode !== 'chart') { alert('SVG 仅图表模式支持'); return; } exportChartSvg(); return; } const prefix = currentMode === 'chart' ? 'chart' : currentMode === 'table' ? 'table' : 'combine'; exportCurrent(prefix); } // ===== 导出表格(走统一导出) ===== function exportTable(format) { exportCurrent('table'); } // ===== 快速切换风格 ===== 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 = '

正在合并...

'; 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 srcCvs = holder.querySelector('canvas'); if (!srcCvs) { reject(new Error('子图渲染失败')); return; } // 关键:先把内容复制到独立 canvas 再 dispose。 // 直接 resolve 原 canvas 的话,Promise 微任务在 chart.dispose() 之后才执行, // 此时 canvas 内容已被 echarts 清空,合并出来就是白图。 const copy = document.createElement('canvas'); copy.width = srcCvs.width; copy.height = srcCvs.height; copy.getContext('2d').drawImage(srcCvs, 0, 0); chart.dispose(); holder.remove(); resolve(copy); }, 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 = `
合并图预览
`; refreshPreview(); }).catch(err => { chartArea.innerHTML = `

${err.message}

`; }); } // 导出合并图(走统一导出) function exportCombine() { exportCurrent('combine'); }