// ===== 全局状态 ===== 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 触发) initCombineCharts(); // 初始化导出设置 renderHistory(); refreshExportHint(); // 收藏:URL 带 id 时,在新标签页加载该收藏配置进行再次制作 const urlParams = new URLSearchParams(location.search); const favId = urlParams.get('id'); if (favId) { loadFavoriteForEdit(favId); } }); // ===== 数据解析(rowsAsSeries=true 时“行=系列”:第一行是横坐标,每行一个系列) ===== function parseData(rawText, rowsAsSeries) { 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 = {}; if (rowsAsSeries) { // 行=系列:第一列=系列名,表头除第一列外=横坐标 const names = []; const nameSet = {}; rows.slice(1).forEach(r => { const n = (r[0] || '').trim(); if (n && !nameSet[n]) { nameSet[n] = true; names.push(n); } }); names.forEach(n => { seriesData[n] = []; }); for (let j = 1; j < headers.length; j++) { categories.push(headers[j]); for (let i = 1; i < rows.length; i++) { const r = rows[i]; const name = ((r && r[0]) || '').trim(); if (!seriesData[name]) continue; const val = parseFloat(r[j]); seriesData[name].push(isNaN(val) ? 0 : val); } } return { categories, seriesNames: names, seriesData, firstField: headers[0] || '' }; } // 第一列是横坐标,其余列是系列 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, firstField: headers[0] || '' }; } // ===== 生成图表 ===== function generateChart() { const rawText = document.getElementById('dataInput').value; if (!rawText.trim()) { alert('请输入数据'); return; } parsedData = parseData(rawText, document.getElementById('dataOrientation').value === 'rows'); if (!parsedData) return; // X轴名默认取数据首列字段名(用户手动编辑过则保持不动) setXAxisNameDefault(parsedData.firstField); // 初始化系列顺序和颜色 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(); }); } // ===== 饼图/雷达图 option 构建 ===== function buildPieRadarOption(chartType) { if (!parsedData || parsedData.seriesNames.length === 0) return null; const title = document.getElementById('chartTitle').value; const theme = document.getElementById('themeStyle').value; const showLegend = document.getElementById('showLegend').checked; const showLabel = document.getElementById('showLabel').checked; const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff'; const textColor = theme === 'dark' ? '#e0e0e0' : '#333333'; const palette = colorPalettes[theme] || colorPalettes.default; const colors = seriesOrder.map((origIdx, di) => seriesColors[origIdx] || palette[di % palette.length]); const baseTitle = title ? { text: title, left: 'center', top: 10, textStyle: { color: textColor, fontSize: 18, fontWeight: 600 } } : undefined; if (chartType === 'pie') { // 饼图:第一列=名称,第一个系列=数值 const sName = parsedData.seriesNames[0]; const data = parsedData.seriesData[sName]; const pieData = parsedData.categories.map((c, i) => ({ name: c, value: data[i] || 0 })); return { backgroundColor: bgColor, title: baseTitle, tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, legend: showLegend ? { orient: 'vertical', left: 'left', top: title ? 50 : 20, textStyle: { color: textColor, fontSize: 12 } } : { show: false }, color: colors, series: [{ type: 'pie', radius: ['38%', '68%'], center: ['52%', '55%'], avoidLabelOverlap: true, itemStyle: { borderRadius: 6, borderColor: bgColor, borderWidth: 2 }, label: { show: showLabel, formatter: '{b}: {d}%', color: textColor }, labelLine: { show: showLabel }, data: pieData }] }; } else { // 雷达图:第一列=维度名,每个系列=一个雷达多边形 const indicator = parsedData.categories.map(c => { let max = 0; parsedData.seriesNames.forEach(n => { parsedData.seriesData[n].forEach(v => { if (v > max) max = v; }); }); return { name: c, max: Math.ceil(max * 1.2) || 100 }; }); return { backgroundColor: bgColor, title: baseTitle, tooltip: { trigger: 'item' }, legend: showLegend ? { orient: 'horizontal', left: 'center', top: title ? 48 : 15, textStyle: { color: textColor, fontSize: 12 } } : { show: false }, color: colors, radar: { indicator, radius: '62%', center: ['50%', '55%'], splitNumber: 5, axisName: { color: textColor, fontSize: 12 }, splitLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } }, splitArea: { areaStyle: { color: theme === 'dark' ? ['rgba(79,195,247,0.03)', 'rgba(79,195,247,0.06)'] : ['rgba(79,70,229,0.03)', 'rgba(79,70,229,0.06)'] } }, axisLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } } }, series: parsedData.seriesNames.map(n => ({ type: 'radar', name: n, data: [{ value: parsedData.seriesData[n], name: n }], symbolSize: 4, areaStyle: { opacity: 0.15 } })) }; } } // ===== 更新图表 ===== 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 leftAxisName = document.getElementById('leftAxisName').value; const rightAxisName = document.getElementById('rightAxisName').value; const leftAxisNameLoc = (document.getElementById('leftAxisNameLoc') || {}).value || 'vertical'; const rightAxisNameLoc = (document.getElementById('rightAxisNameLoc') || {}).value || 'vertical'; const xAxisName = document.getElementById('xAxisName').value.trim(); const xAxisNameLoc = document.getElementById('xAxisNameLoc').value; const xLabelRotate = xLabelRotateValue(); // 数据标签颜色:auto=同系列颜色 / custom=手动指定 const labelColorMode = (document.getElementById('labelColorMode') || {}).value || 'auto'; const labelColor = (document.getElementById('labelColor') || {}).value || '#333333'; // Y轴范围(留空=自动) const yAxisMin = (document.getElementById('yAxisMin') || {}).value; const yAxisMax = (document.getElementById('yAxisMax') || {}).value; const leftAxisMin = (document.getElementById('leftAxisMin') || {}).value; const leftAxisMax = (document.getElementById('leftAxisMax') || {}).value; const rightAxisMin = (document.getElementById('rightAxisMin') || {}).value; const rightAxisMax = (document.getElementById('rightAxisMax') || {}).value; // 显示/隐藏:数据标签颜色 + 单/双轴范围输入 const lcGroup = document.getElementById('labelColorGroup'); if (lcGroup) lcGroup.style.display = showLabel ? 'block' : 'none'; const singleR = document.getElementById('singleAxisRange'); const dualR = document.getElementById('dualAxisRange'); if (singleR) singleR.style.display = dualAxis ? 'none' : 'block'; if (dualR) dualR.style.display = dualAxis ? 'block' : 'none'; // 饼图/雷达图:走专用构建逻辑(无坐标轴/网格,不支持双轴/堆叠/分割) if (chartType === 'pie' || chartType === 'radar') { const option = buildPieRadarOption(chartType); if (option) { chartInstance.setOption(option, true); refreshPreview(); } return; } // 显示/隐藏分割配置 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]; // 系列类型:优先用系列配置的独立覆盖(支持不同轴不同图表类型) const override = window.seriesTypeOverrides && window.seriesTypeOverrides[origIdx]; let type; if (override && override !== 'auto') { type = override; } else if (chartType === 'bar-line') { type = displayIdx % 2 === 0 ? 'bar' : 'line'; } else { type = 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) } ]); } } // 系列细分样式:柱状(实体/空心/阴影) / 折线(实线/虚线/点线) const styleOv = window.seriesStyleOverrides && window.seriesStyleOverrides[origIdx]; applySeriesStyle(seriesItem, type, color, styleOv); // 数据标签:颜色默认同系列颜色,可手动自定义 if (showLabel) { seriesItem.label = { show: true, position: type === 'bar' ? 'top' : 'top', fontSize: 11, color: labelColorMode === 'custom' ? labelColor : color, 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; } // 图表配置:双Y轴图例放到各自轴上方(左图例靠左、右图例靠右),不再占用两侧空白,grid 全宽可用 // 图例图标按系列类型自动(柱状=方块,折线=线条) let legendConfig, gridLeftCfg, gridRightCfg, dualGridTop = null; if (dualAxis && showLegend) { const left = [], right = []; seriesOrder.forEach(origIdx => { const name = parsedData.seriesNames[origIdx]; (seriesAxis[origIdx] === 1 ? right : left).push(name); }); const legendTop = title ? 42 : 8; const chartW = chartInstance ? chartInstance.getWidth() : 800; const availW = Math.max(100, (chartW - 20) / 2); const rows = Math.max(estimateLegendRows(left, 12, availW), estimateLegendRows(right, 12, availW)); const legendH = rows * 22 + 6; // 每行约 22px // 轴名在顶部时需额外留空间,避免被图例遮挡;竖直显示时不占顶部空间 const needNameTopGap = (leftAxisNameLoc === 'top' || rightAxisNameLoc === 'top'); legendConfig = [ { show: left.length > 0, orient: 'horizontal', left: 8, top: legendTop, width: '49%', data: left, title: left.length > 1 ? { text: '左轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined, textStyle: { color: textColor, fontSize: 12 }, itemGap: 14 }, { show: right.length > 0, orient: 'horizontal', right: 8, top: legendTop, width: '49%', data: right, title: right.length > 1 ? { text: '右轴', textStyle: { color: textColor, fontSize: 11, fontWeight: 600 } } : undefined, textStyle: { color: textColor, fontSize: 12 }, itemGap: 14 } ]; gridLeftCfg = '3%'; gridRightCfg = '4%'; dualGridTop = legendTop + legendH + (needNameTopGap ? 24 : 8); } else { legendConfig = showLegend ? { show: true, top: title ? 44 : 10, textStyle: { color: textColor, fontSize: 12 }, itemGap: 20 } : { show: false }; gridLeftCfg = '3%'; gridRightCfg = '4%'; if (dualAxis) dualGridTop = title ? 46 : 20; } // X轴名占位:右侧末端需加右 padding,轴下方居中需加下 padding(避免文字被截断) let gridBottomCfg = '3%'; if (xAxisName) { const chartW = chartInstance ? chartInstance.getWidth() : 800; const nameWidth = measureTextWidth(xAxisName, 12); if (xAxisNameLoc === 'end') { const curRight = typeof gridRightCfg === 'number' ? gridRightCfg : 0.04 * chartW; gridRightCfg = curRight + nameWidth + 26; // nameGap(15) + 边距 } else if (xAxisNameLoc === 'middle') { gridBottomCfg = 0.03 * chartW + 32; } } 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: legendConfig, grid: { left: gridLeftCfg, right: gridRightCfg, bottom: gridBottomCfg, top: dualGridTop !== null ? dualGridTop : (showLegend ? (title ? 90 : 60) : (title ? 60 : 30)), containLabel: true }, xAxis: { type: 'category', name: xAxisName, nameLocation: xAxisNameLoc, nameGap: 20, nameTextStyle: { color: textColor, fontSize: 12, fontWeight: 600 }, data: parsedData.categories, axisLine: { lineStyle: { color: axisLineColor } }, axisLabel: { color: textColor, fontSize: 12, interval: 0, rotate: xLabelRotate }, axisTick: { show: false } }, yAxis: dualAxis ? [ { type: 'value', name: leftAxisName || '左轴', min: axisNum(leftAxisMin), max: axisNum(leftAxisMax), nameLocation: leftAxisNameLoc === 'top' ? 'end' : 'middle', nameRotate: leftAxisNameLoc === 'top' ? 0 : 90, nameGap: leftAxisNameLoc === 'top' ? 12 : 32, nameTextStyle: { color: textColor, fontSize: 11 }, 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', min: axisNum(rightAxisMin), max: axisNum(rightAxisMax), nameLocation: rightAxisNameLoc === 'top' ? 'end' : 'middle', nameRotate: rightAxisNameLoc === 'top' ? 0 : 90, nameGap: rightAxisNameLoc === 'top' ? 12 : 32, nameTextStyle: { color: textColor, fontSize: 11 }, axisLine: { show: false }, axisTick: { show: false }, axisLabel: { color: textColor, fontSize: 11 }, splitLine: { show: false } } ] : { type: 'value', min: axisNum(yAxisMin), max: axisNum(yAxisMax), 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 typeOv = window.seriesTypeOverrides && window.seriesTypeOverrides[origIdx]; const styleOv = (window.seriesStyleOverrides && window.seriesStyleOverrides[origIdx]) || 'solid'; const effType = (typeOv && typeOv !== 'auto') ? typeOv : 'bar'; const styleOptions = effType === 'line' ? [['solid', '实线'], ['dashed', '虚线'], ['dotted', '点线']] : [['solid', '实体'], ['hollow', '空心'], ['shadow', '阴影']]; const styleSelect = ` `; const item = document.createElement('div'); item.className = 'series-item'; item.draggable = true; item.dataset.index = displayIdx; item.innerHTML = ` ${name} ${styleSelect} ${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; renderSeriesConfig(); // 样式选项随柱状/折线联动 updateChart(); } function updateSeriesStyle(origIdx, style) { if (!window.seriesStyleOverrides) window.seriesStyleOverrides = {}; window.seriesStyleOverrides[origIdx] = style; updateChart(); } // ===== 双Y轴 ===== function onDualAxisChange() { const on = document.getElementById('dualAxis').checked; document.getElementById('rightAxisGroup').style.display = on ? 'block' : 'none'; const singleR = document.getElementById('singleAxisRange'); const dualR = document.getElementById('dualAxisRange'); if (singleR) singleR.style.display = on ? 'none' : 'block'; if (dualR) dualR.style.display = on ? 'block' : 'none'; renderSeriesConfig(); // 显示/隐藏系列轴选择 updateChart(); } function updateSeriesAxis(origIdx, axis) { seriesAxis[origIdx] = parseInt(axis) || 0; updateChart(); } // ===== X轴刻度文字方向:下拉(10°档) + 自定义手动输入 ===== function xLabelRotateValue() { const sel = document.getElementById('xLabelRotate'); if (sel && sel.value === 'custom') { return parseInt(document.getElementById('xLabelRotateCustom').value) || 0; } return sel ? (parseInt(sel.value) || 0) : 0; } function onXLabelRotateChange() { const sel = document.getElementById('xLabelRotate'); const custom = document.getElementById('xLabelRotateCustom'); if (custom) custom.style.display = (sel && sel.value === 'custom') ? 'block' : 'none'; updateChart(); } function onXLabelRotateCustomInput() { const v = parseInt(document.getElementById('xLabelRotateCustom').value) || 0; const sel = document.getElementById('xLabelRotate'); const custom = document.getElementById('xLabelRotateCustom'); // 输入值命中 10° 档位时自动同步下拉显示 if (v >= 0 && v <= 90 && v % 10 === 0) { sel.value = String(v); if (custom) custom.style.display = 'none'; } else { sel.value = 'custom'; if (custom) custom.style.display = 'block'; } updateChart(); } // ===== 工具函数 ===== // 测量文字宽度(用于轴名/图例占位计算) function measureTextWidth(text, fontSize) { fontSize = fontSize || 12; if (!text) return 0; const c = document.createElement('canvas'); const ctx = c.getContext('2d'); ctx.font = `${fontSize}px sans-serif`; return ctx.measureText(text).width; } // 估算水平图例的换行行数(双轴顶部图例用,避免图例与绘图区重叠) function estimateLegendRows(names, fontSize, availWidth) { if (!names || !names.length) return 0; const c = document.createElement('canvas'); const ctx = c.getContext('2d'); ctx.font = `${fontSize}px sans-serif`; const icon = 14, sw = 6, ig = 14; // 图标宽度 + 图标与文字间距 + itemGap let cur = 0, rows = 1; names.forEach(n => { const itemW = icon + sw + ctx.measureText(n).width; if (cur && cur + ig + itemW > availWidth) { rows++; cur = itemW; } else cur += itemW + (cur ? ig : 0); }); return rows; } function hexToRgba(hex, alpha) { hex = String(hex || '#888').replace('#', ''); if (hex.length === 3) hex = hex.split('').map(c => c + c).join(''); const num = parseInt(hex, 16); return `rgba(${(num >> 16) & 255},${(num >> 8) & 255},${num & 255},${alpha})`; } // 系列细分样式:柱状(实体/空心/阴影),折线(实线/虚线/点线) function applySeriesStyle(seriesItem, type, color, style) { if (!style || style === 'solid') return; if (type === 'bar') { if (style === 'hollow') { const fill = (typeof color === 'string' && color.charAt(0) === '#') ? hexToRgba(color, 0.12) : 'rgba(120,120,120,0.12)'; seriesItem.itemStyle.color = fill; seriesItem.itemStyle.borderColor = (typeof color === 'string' && color.charAt(0) === '#') ? color : '#888'; seriesItem.itemStyle.borderWidth = 2; } else if (style === 'shadow') { seriesItem.itemStyle.shadowBlur = 14; seriesItem.itemStyle.shadowColor = (typeof color === 'string' && color.charAt(0) === '#') ? hexToRgba(color, 0.55) : 'rgba(0,0,0,0.35)'; seriesItem.itemStyle.shadowOffsetY = 4; } } else if (type === 'line') { if (!seriesItem.lineStyle) seriesItem.lineStyle = { width: 3 }; if (style === 'dashed') seriesItem.lineStyle.type = 'dashed'; else if (style === 'dotted') seriesItem.lineStyle.type = 'dotted'; } } // ===== X轴名:默认取数据首列字段名,用户手动编辑后不再自动覆盖 ===== let xAxisNameTouched = false; function onXAxisNameInput() { xAxisNameTouched = true; updateChart(); } function setXAxisNameDefault(firstField) { const input = document.getElementById('xAxisName'); if (!input) return; if (!xAxisNameTouched && firstField && input.value.trim() !== firstField) { input.value = firstField; } } // Y轴 min/max:空值或非法返回 undefined(走 ECharts 自动) function axisNum(v) { if (v === '' || v === undefined || v === null) return undefined; const n = parseFloat(v); return isNaN(n) ? undefined : n; } // 数据标签颜色模式切换:auto=同系列颜色 / custom=自定义色 function onLabelColorModeChange() { const colorInput = document.getElementById('labelColor'); if (colorInput) colorInput.style.display = document.getElementById('labelColorMode').value === 'custom' ? 'block' : 'none'; 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 = '

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

'; } // 数据方向切换(列=系列 / 行=系列):重新解析并重建系列配置 function onDataOrientationChange() { if (currentMode === 'chart' && document.getElementById('dataInput').value.trim()) { generateChart(); } } // ===== 导出图表(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, !!cfg.rowsAsSeries); if (!parsed) return null; const { title = '', chartType = 'bar', theme = 'default', showLegend = true, showGrid = true, showLabel = false, stackMode = false, smoothLine = true, dualYAxis = false, leftAxisName = '', rightAxisName = '', seriesConfig = null, leftAxisNameLoc = 'vertical', rightAxisNameLoc = 'vertical', labelColorMode = 'auto', labelColor = '#333333', yAxisMin = '', yAxisMax = '', leftAxisMin = '', leftAxisMax = '', rightAxisMin = '', rightAxisMax = '', xAxisName = '', xAxisNameLoc = 'middle', xLabelRotate = 0, width = 600 } = 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; // 饼图/雷达图:专用构建逻辑 if (chartType === 'pie' || chartType === 'radar') { if (chartType === 'pie') { const sName = parsed.seriesNames[0]; const data = parsed.seriesData[sName]; const pieData = parsed.categories.map((c, i) => ({ name: c, value: data[i] || 0 })); return { backgroundColor: bgColor, title: title ? { text: title, left: 'center', top: 10, textStyle: { color: textColor, fontSize: 16, fontWeight: 600 } } : undefined, tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, legend: showLegend ? { orient: 'vertical', left: 'left', top: title ? 40 : 10, textStyle: { color: textColor, fontSize: 12 } } : { show: false }, color: palette, series: [{ type: 'pie', radius: ['38%', '68%'], center: ['55%', '55%'], itemStyle: { borderRadius: 6, borderColor: bgColor, borderWidth: 2 }, label: { show: showLabel, formatter: '{b}: {d}%', color: textColor }, labelLine: { show: showLabel }, data: pieData }], animation: false }; } else { const indicator = parsed.categories.map(c => { let max = 0; parsed.seriesNames.forEach(n => { parsed.seriesData[n].forEach(v => { if (v > max) max = v; }); }); return { name: c, max: Math.ceil(max * 1.2) || 100 }; }); return { backgroundColor: bgColor, title: title ? { text: title, left: 'center', top: 10, textStyle: { color: textColor, fontSize: 16, fontWeight: 600 } } : undefined, tooltip: { trigger: 'item' }, legend: showLegend ? { orient: 'horizontal', left: 'center', top: title ? 38 : 10, textStyle: { color: textColor, fontSize: 12 } } : { show: false }, color: palette, radar: { indicator, radius: '62%', center: ['50%', '55%'], splitNumber: 5, axisName: { color: textColor, fontSize: 11 }, splitLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } }, splitArea: { areaStyle: { color: theme === 'dark' ? ['rgba(79,195,247,0.03)', 'rgba(79,195,247,0.06)'] : ['rgba(79,70,229,0.03)', 'rgba(79,70,229,0.06)'] } }, axisLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } } }, series: parsed.seriesNames.map(n => ({ type: 'radar', name: n, data: [{ value: parsed.seriesData[n], name: n }], symbolSize: 4, areaStyle: { opacity: 0.15 } })), animation: false }; } } const scMap = {}; (Array.isArray(seriesConfig) ? seriesConfig : []).forEach(s => { if (s && s.name) scMap[s.name] = s; }); const series = parsed.seriesNames.map((name, idx) => { const color = palette[idx % palette.length]; const sc = scMap[name] || {}; let type = chartType === 'bar-line' ? (idx % 2 === 0 ? 'bar' : 'line') : chartType; if (sc.type && sc.type !== 'auto') type = sc.type; const s = { name, type, data: [...parsed.seriesData[name]], itemStyle: { color }, emphasis: { focus: 'series' } }; if (dualYAxis) s.yAxisIndex = (sc.axis === 1 || sc.axis === '1') ? 1 : 0; 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) } ]); } } // 系列细分样式:柱状(实体/空心/阴影) / 折线(实线/虚线/点线) applySeriesStyle(s, type, color, sc.style); if (showLabel) { s.label = { show: true, position: 'top', fontSize: 11, color: labelColorMode === 'custom' ? labelColor : color, 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; }); // 双Y轴图例放到各自轴上方(左靠左、右靠右),不再占用两侧空白;图标按系列类型自动 let legendCfg, gridLeftCfg, gridRightCfg, dualGridTop = null; if (dualYAxis && showLegend) { const left = [], right = []; parsed.seriesNames.forEach(name => { const sc = scMap[name] || {}; ((sc.axis === 1 || sc.axis === '1') ? right : left).push(name); }); const legendTop = title ? 38 : 8; const availW = Math.max(80, (width - 16) / 2); const rows = Math.max(estimateLegendRows(left, 11, availW), estimateLegendRows(right, 11, availW)); const legendH = rows * 20 + 4; // 每行约 20px const needNameTopGap = (leftAxisNameLoc === 'top' || rightAxisNameLoc === 'top'); legendCfg = [ { show: left.length > 0, orient: 'horizontal', left: 6, top: legendTop, width: '49%', data: left, title: left.length > 1 ? { text: '左轴', textStyle: { color: textColor, fontSize: 10, fontWeight: 600 } } : undefined, textStyle: { color: textColor, fontSize: 11 }, itemGap: 10 }, { show: right.length > 0, orient: 'horizontal', right: 6, top: legendTop, width: '49%', data: right, title: right.length > 1 ? { text: '右轴', textStyle: { color: textColor, fontSize: 10, fontWeight: 600 } } : undefined, textStyle: { color: textColor, fontSize: 11 }, itemGap: 10 } ]; gridLeftCfg = '3%'; gridRightCfg = '4%'; dualGridTop = legendTop + legendH + (needNameTopGap ? 20 : 6); } else { legendCfg = showLegend ? { show: true, top: title ? 38 : 8, textStyle: { color: textColor, fontSize: 12 }, itemGap: 20 } : { show: false }; gridLeftCfg = '3%'; gridRightCfg = '4%'; if (dualYAxis) dualGridTop = title ? 42 : 16; } // X轴名占位:右侧末端加右 padding,轴下方居中加下 padding(避免文字被截断) let gridBottomCfg = '3%'; if (xAxisName) { const nameWidth = measureTextWidth(xAxisName, 11); if (xAxisNameLoc === 'end') { const curRight = typeof gridRightCfg === 'number' ? gridRightCfg : 0.04 * 800; gridRightCfg = curRight + nameWidth + 22; } else if (xAxisNameLoc === 'middle') { gridBottomCfg = 0.03 * 800 + 30; } } 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: legendCfg, grid: { left: gridLeftCfg, right: gridRightCfg, bottom: gridBottomCfg, top: dualGridTop !== null ? dualGridTop : (showLegend ? (title ? 70 : 45) : (title ? 50 : 30)), containLabel: true }, xAxis: { type: 'category', name: xAxisName, nameLocation: xAxisNameLoc, nameGap: 16, nameTextStyle: { color: textColor, fontSize: 11, fontWeight: 600 }, data: parsed.categories, axisLine: { lineStyle: { color: axisLineColor } }, axisLabel: { color: textColor, fontSize: 11, interval: 0, rotate: xLabelRotate }, axisTick: { show: false } }, yAxis: dualYAxis ? [ { type: 'value', name: leftAxisName || '左轴', min: axisNum(leftAxisMin), max: axisNum(leftAxisMax), nameLocation: leftAxisNameLoc === 'top' ? 'end' : 'middle', nameRotate: leftAxisNameLoc === 'top' ? 0 : 90, nameGap: leftAxisNameLoc === 'top' ? 12 : 30, nameTextStyle: { color: textColor, fontSize: 11 }, 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', min: axisNum(rightAxisMin), max: axisNum(rightAxisMax), nameLocation: rightAxisNameLoc === 'top' ? 'end' : 'middle', nameRotate: rightAxisNameLoc === 'top' ? 0 : 90, nameGap: rightAxisNameLoc === 'top' ? 12 : 30, nameTextStyle: { color: textColor, fontSize: 11 }, axisLine: { show: false }, axisTick: { show: false }, axisLabel: { color: textColor, fontSize: 11 }, splitLine: { show: false } } ] : { type: 'value', min: axisNum(yAxisMin), max: axisNum(yAxisMax), 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, rowsAsSeries) { const p = parseData(dataText, !!rowsAsSeries); if (!p) return { w: 600, h: 400 }; const w = Math.min(900, Math.max(500, p.categories.length * 70 + 140)); return { w, h: 400 }; } // ===== 多图合并管理 ===== let combineCharts = []; function defaultCombineChart() { return { title: '', type: 'bar', theme: 'default', legend: true, grid: true, label: false, stack: false, data: '', rowsAsSeries: false, dualYAxis: false, leftAxisName: '', rightAxisName: '', leftAxisNameLoc: 'vertical', rightAxisNameLoc: 'vertical', labelColorMode: 'auto', labelColor: '#333333', yAxisMin: '', yAxisMax: '', leftAxisMin: '', leftAxisMax: '', rightAxisMin: '', rightAxisMax: '', seriesConfig: [], xAxisName: '', xAxisNameLoc: 'middle', xLabelRotate: 0 }; } 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', '柱状图+折线图混合'], ['pie', '饼图'], ['radar', '雷达图']]; 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, '"'); } function renderCombineCharts() { const container = document.getElementById('combineChartsContainer'); if (!container) return; container.innerHTML = combineCharts.map((c, i) => `

📈 图${i + 1}

${c.dualYAxis ? `
~
~
` : `
~
`}
${c.dualYAxis ? `
/
/
` : ''}
°
${renderCombineSeriesConfigHTML(i)}
`).join(''); } function updateCombineChart(i, field, value) { if (!combineCharts[i]) return; combineCharts[i][field] = value; if (field === 'dualYAxis' || field === 'rowsAsSeries') { syncCombineSeriesConfig(i); renderCombineCharts(); generateCombine(); return; } if (field === 'data') { syncCombineSeriesConfig(i); const block = document.getElementById('combineSeriesCfg' + i); if (block) block.innerHTML = renderCombineSeriesConfigHTML(i); } if (field === 'type') { // 样式选项随柱状/折线联动 const block = document.getElementById('combineSeriesCfg' + i); if (block) block.innerHTML = renderCombineSeriesConfigHTML(i); } generateCombine(); } // ===== 多图合并:系列配置(每行/列可独立设图表类型与左右轴) ===== function syncCombineSeriesConfig(i) { const c = combineCharts[i]; if (!c) return; const p = parseData(c.data, !!c.rowsAsSeries); if (!p) return; const old = Array.isArray(c.seriesConfig) ? c.seriesConfig : []; const oldMap = {}; old.forEach(o => { if (o && o.name) oldMap[o.name] = o; }); c.seriesConfig = p.seriesNames.map(n => { const prev = oldMap[n] || {}; return { name: n, type: prev.type || 'auto', axis: prev.axis !== undefined ? prev.axis : 0, style: prev.style || 'solid' }; }); } function renderCombineSeriesConfigHTML(i) { const c = combineCharts[i]; if (!c) return '

输入数据后自动显示各系列的图表类型/坐标轴

'; syncCombineSeriesConfig(i); const p = parseData(c.data, !!c.rowsAsSeries); if (!p || !p.seriesNames.length) return '

输入数据后自动显示各系列的图表类型/坐标轴

'; const dual = !!c.dualYAxis; return p.seriesNames.map((name, idx) => { const sc = (c.seriesConfig && c.seriesConfig[idx]) || { name, type: 'auto', axis: 0, style: 'solid' }; const effType = (sc.type && sc.type !== 'auto') ? sc.type : 'bar'; const styleOptions = effType === 'line' ? [['solid', '实线'], ['dashed', '虚线'], ['dotted', '点线']] : [['solid', '实体'], ['hollow', '空心'], ['shadow', '阴影']]; return `
${escHtml(name)} ${dual ? `` : ''}
`; }).join(''); } function updateCombineSeriesCfg(i, idx, field, value) { const c = combineCharts[i]; if (!c) return; syncCombineSeriesConfig(i); if (c.seriesConfig && c.seriesConfig[idx]) c.seriesConfig[idx][field] = value; generateCombine(); } // ===== 多图合并:X轴刻度方向(下拉10°档 + 自定义输入) ===== function combineRotateSelValue(v) { v = parseInt(v) || 0; return (v >= 0 && v <= 90 && v % 10 === 0) ? String(v) : 'custom'; } function combineRotateValue(i) { const sel = document.getElementById('combineRotateSel' + i); if (!sel) return 0; if (sel.value === 'custom') { const custom = document.getElementById('combineRotateCustom' + i); return parseInt(custom && custom.value) || 0; } return parseInt(sel.value) || 0; } function onCombineRotateSel(i, val) { const custom = document.getElementById('combineRotateCustom' + i); if (custom) custom.style.display = val === 'custom' ? 'block' : 'none'; if (combineCharts[i]) { combineCharts[i].xLabelRotate = combineRotateValue(i); generateCombine(); } } function onCombineRotateCustom(i, val) { const v = parseInt(val) || 0; const sel = document.getElementById('combineRotateSel' + i); const custom = document.getElementById('combineRotateCustom' + i); if (v >= 0 && v <= 90 && v % 10 === 0) { if (sel) sel.value = String(v); if (custom) custom.style.display = 'none'; } else { if (sel) sel.value = 'custom'; if (custom) custom.style.display = 'block'; } if (combineCharts[i]) { combineCharts[i].xLabelRotate = v; generateCombine(); } } // 多图合并:共享图例条目(所有子图系列合并,按名称去重) function buildCombineLegendItems() { const items = []; const seen = {}; combineCharts.forEach(c => { if (!c.data || !c.data.trim()) return; const p = parseData(c.data, !!c.rowsAsSeries); if (!p) return; const palette = colorPalettes[c.theme] || colorPalettes.default; p.seriesNames.forEach((name, idx) => { if (!seen[name]) { seen[name] = true; items.push({ name, color: palette[idx % palette.length] }); } }); }); return items; } function addCombineChart() { combineCharts.push(defaultCombineChart()); renderCombineCharts(); generateCombine(); } function removeCombineChart(i) { if (combineCharts.length <= 1) { alert('至少保留一张图'); return; } combineCharts.splice(i, 1); renderCombineCharts(); generateCombine(); } // 多行多列切换:显示/隐藏列数输入 function onCombineGridToggle() { const grid = document.querySelector('input[name="combineDirection"]:checked').value === 'grid'; document.getElementById('gridColsGroup').style.display = grid ? 'block' : 'none'; generateCombine(); } // 生成合并图(多图) function generateCombine() { 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 bigTitle = document.getElementById('combineBigTitle').value; const legendMode = document.getElementById('combineLegendMode').value; // own | shared-top | shared-bottom const sharedLegend = legendMode !== 'own'; const chartArea = document.getElementById('chartArea'); chartArea.innerHTML = '

正在合并...

'; const renderOne = (c, w, h) => new Promise((resolve, reject) => { const option = buildCombineChartOption(c.data, { title: c.title, chartType: c.type, theme: c.theme, showLegend: sharedLegend ? false : c.legend, showGrid: c.grid, showLabel: c.label, stackMode: c.stack, rowsAsSeries: !!c.rowsAsSeries, dualYAxis: !!c.dualYAxis, leftAxisName: c.leftAxisName || '', rightAxisName: c.rightAxisName || '', leftAxisNameLoc: c.leftAxisNameLoc || 'vertical', rightAxisNameLoc: c.rightAxisNameLoc || 'vertical', labelColorMode: c.labelColorMode || 'auto', labelColor: c.labelColor || '#333333', yAxisMin: c.yAxisMin !== undefined ? c.yAxisMin : '', yAxisMax: c.yAxisMax !== undefined ? c.yAxisMax : '', leftAxisMin: c.leftAxisMin !== undefined ? c.leftAxisMin : '', leftAxisMax: c.leftAxisMax !== undefined ? c.leftAxisMax : '', rightAxisMin: c.rightAxisMin !== undefined ? c.rightAxisMin : '', rightAxisMax: c.rightAxisMax !== undefined ? c.rightAxisMax : '', xAxisName: c.xAxisName || '', xAxisNameLoc: c.xAxisNameLoc || 'middle', xLabelRotate: parseInt(c.xLabelRotate) || 0, seriesConfig: c.seriesConfig, width: w }); 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,避免 dispose 清空内容 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); }); Promise.all(validCharts.map(c => { const m = measureCombineChart(c.data, c.rowsAsSeries); return renderOne(c, m.w, m.h); })).then(canvases => { // ---- 基础布局(不含大标题/共享图例)---- let W, H, cells = []; if (direction === 'grid') { const cols = Math.max(1, parseInt(document.getElementById('gridCols').value) || 2); const rows = Math.ceil(canvases.length / cols); const cellW = Math.max(...canvases.map(c => c.width)); const cellH = Math.max(...canvases.map(c => c.height)); W = cols * cellW + (cols - 1) * gap; H = rows * cellH + (rows - 1) * gap; canvases.forEach((c, i) => { const r = Math.floor(i / cols), col = i % cols; const scale = Math.min(cellW / c.width, cellH / c.height); const dw = Math.round(c.width * scale), dh = Math.round(c.height * scale); cells.push({ c, x: col * (cellW + gap) + Math.round((cellW - dw) / 2), y: r * (cellH + gap) + Math.round((cellH - dh) / 2), w: dw, h: dh }); }); } else if (direction === 'vertical') { const tw = Math.max(...canvases.map(c => c.width)); let y = 0; canvases.forEach(c => { const h = Math.round(c.height * tw / c.width); cells.push({ c, x: 0, y, w: tw, h }); y += h + gap; }); W = tw; H = y - gap; } else { const th = Math.max(...canvases.map(c => c.height)); let x = 0; canvases.forEach(c => { const w = Math.round(c.width * th / c.height); cells.push({ c, x, y: 0, w, h: th }); x += w + gap; }); W = x - gap; H = th; } // ---- 共享图例:按宽度换行计算 ---- const legendFont = 13, swatch = 14, sw = 5, ig = 18, lineH = legendFont + 10; let legendRows = [], legendH = 0; if (sharedLegend) { const items = buildCombineLegendItems(); const tc = document.createElement('canvas'); tc.width = 10; tc.height = 10; const tctx = tc.getContext('2d'); tctx.font = `${legendFont}px sans-serif`; const maxW = W - 20; let cur = [], curW = 0; items.forEach(it => { const itemW = swatch + sw + tctx.measureText(it.name).width; if (cur.length && curW + ig + itemW > maxW) { legendRows.push(cur); cur = []; curW = 0; } cur.push(it); curW += itemW + (cur.length > 1 ? ig : 0); }); if (cur.length) legendRows.push(cur); legendH = legendRows.length * lineH + 8; } // ---- 大标题区高度 ---- const titleFont = 34; const titleH = bigTitle ? titleFont + 26 : 0; const topOffset = titleH + (legendMode === 'shared-top' ? legendH + 12 : 0); const bottomOffset = legendMode === 'shared-bottom' ? legendH + 12 : 0; const totalH = H + topOffset + bottomOffset; // ---- 绘制最终画布 ---- const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = totalH; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, W, totalH); if (bigTitle) { ctx.font = `bold ${titleFont}px sans-serif`; ctx.fillStyle = '#333333'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(bigTitle, W / 2, (titleH - 26) / 2 + 2); } const drawLegendBlock = (topY) => { legendRows.forEach((row, ri) => { ctx.font = `${legendFont}px sans-serif`; let totalW = 0; row.forEach(it => { totalW += swatch + sw + ctx.measureText(it.name).width + ig; }); totalW -= ig; let cx = (W - totalW) / 2; row.forEach(it => { ctx.fillStyle = it.color; ctx.fillRect(cx, topY + ri * lineH + (legendFont - swatch) / 2 + 2, swatch, swatch); ctx.fillStyle = '#333333'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.font = `${legendFont}px sans-serif`; ctx.fillText(it.name, cx + swatch + sw, topY + ri * lineH + legendFont / 2 + 2); cx += swatch + sw + ctx.measureText(it.name).width + ig; }); }); }; if (sharedLegend && legendMode === 'shared-top') { drawLegendBlock(titleH + 4); } cells.forEach(cell => { ctx.drawImage(cell.c, cell.x, cell.y + topOffset, cell.w, cell.h); }); if (sharedLegend && legendMode === 'shared-bottom') { drawLegendBlock(topOffset + H + 8); } combinedCanvas = canvas; const url = canvas.toDataURL('image/png'); chartArea.innerHTML = `
合并图预览
`; refreshPreview(); }).catch(err => { chartArea.innerHTML = `

${err.message}

`; }); } // 导出合并图(走统一导出) function exportCombine() { exportCurrent('combine'); } // ===== 收藏功能 ===== function collectChartConfig() { return { mode: 'chart', data: document.getElementById('dataInput').value, rowsAsSeries: document.getElementById('dataOrientation').value === 'rows', chartType: document.getElementById('chartType').value, title: document.getElementById('chartTitle').value, theme: document.getElementById('themeStyle').value, showLegend: document.getElementById('showLegend').checked, showGrid: document.getElementById('showGrid').checked, showLabel: document.getElementById('showLabel').checked, stackMode: document.getElementById('stackMode').checked, smoothLine: document.getElementById('smoothLine').checked, dualAxis: document.getElementById('dualAxis').checked, leftAxisName: document.getElementById('leftAxisName').value, rightAxisName: document.getElementById('rightAxisName').value, leftAxisNameLoc: (document.getElementById('leftAxisNameLoc') || {}).value || 'vertical', rightAxisNameLoc: (document.getElementById('rightAxisNameLoc') || {}).value || 'vertical', labelColorMode: (document.getElementById('labelColorMode') || {}).value || 'auto', labelColor: (document.getElementById('labelColor') || {}).value || '#333333', yAxisMin: (document.getElementById('yAxisMin') || {}).value || '', yAxisMax: (document.getElementById('yAxisMax') || {}).value || '', leftAxisMin: (document.getElementById('leftAxisMin') || {}).value || '', leftAxisMax: (document.getElementById('leftAxisMax') || {}).value || '', rightAxisMin: (document.getElementById('rightAxisMin') || {}).value || '', rightAxisMax: (document.getElementById('rightAxisMax') || {}).value || '', xAxisName: document.getElementById('xAxisName').value, xAxisNameLoc: document.getElementById('xAxisNameLoc').value, xLabelRotate: xLabelRotateValue(), enableSplit: document.getElementById('enableSplit').checked, splitIndex: parseInt(document.getElementById('splitIndex').value) || 3, leftLabel: document.getElementById('leftLabel').value, rightLabel: document.getElementById('rightLabel').value, splitStyle: document.getElementById('splitStyle').value, seriesColors: [...seriesColors], seriesOrder: [...seriesOrder], seriesAxis: [...seriesAxis], seriesTypeOverrides: window.seriesTypeOverrides || {}, seriesStyleOverrides: window.seriesStyleOverrides || {} }; } function collectTableConfig() { return { mode: 'table', data: document.getElementById('dataInput').value, 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 }; } function collectCombineConfig() { return { mode: 'combine', charts: combineCharts.map(c => ({ ...c, seriesConfig: Array.isArray(c.seriesConfig) ? c.seriesConfig.map(s => ({ ...s })) : [] })), direction: (document.querySelector('input[name="combineDirection"]:checked') || {}).value || 'horizontal', gridCols: parseInt(document.getElementById('gridCols').value) || 2, bigTitle: document.getElementById('combineBigTitle').value, legendMode: document.getElementById('combineLegendMode').value }; } function collectConfig() { if (currentMode === 'chart') return collectChartConfig(); if (currentMode === 'table') return collectTableConfig(); return collectCombineConfig(); } function favoriteTitle(cfg) { if (cfg.mode === 'chart') return cfg.title || '未命名图表'; if (cfg.mode === 'table') return cfg.title || '未命名表格'; return cfg.bigTitle || '多图合并'; } // 点击「⭐ 收藏」:把当前图/表按原始大小保存到收藏区 function hasFavoriteContent(cfg) { if (cfg.mode === 'combine') { return Array.isArray(cfg.charts) && cfg.charts.some(c => c.data && c.data.trim()); } return !!(cfg.data && cfg.data.trim()); } function saveFavorite() { let cfg; try { cfg = collectConfig(); } catch (e) { alert(e.message); return; } if (!hasFavoriteContent(cfg)) { alert('没有可收藏的内容,请先生成图或表'); return; } const btn = document.getElementById('btnFavorite'); if (btn) { btn.disabled = true; btn.textContent = '⏳ 收藏中...'; } getSourceCanvas() .then(canvas => { const image = canvas.toDataURL('image/png'); const title = favoriteTitle(cfg); return fetch('/api/favorites', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: cfg.mode, title, config: cfg, image }) }); }) .then(res => res.json().then(data => ({ ok: res.ok, data }))) .then(({ ok, data }) => { if (!ok) throw new Error((data && data.error) || '收藏失败'); showToast('⭐ 已收藏:' + (data.title || '')); refreshFavorites(); }) .catch(err => alert(err.message)) .finally(() => { if (btn) { btn.disabled = false; btn.textContent = '⭐ 收藏'; } }); } // ===== 收藏区(弹窗) ===== function openFavorites() { const modal = document.getElementById('favoritesModal'); if (modal) modal.style.display = 'flex'; refreshFavorites(); } function closeFavorites() { const modal = document.getElementById('favoritesModal'); if (modal) modal.style.display = 'none'; } function refreshFavorites() { fetch('/api/favorites') .then(res => res.json()) .then(data => renderFavorites((data && data.favorites) || [])) .catch(err => { const list = document.getElementById('favoritesList'); if (list) list.innerHTML = `
加载失败:${escHtml(err.message)}
`; }); } function renderFavorites(favs) { const list = document.getElementById('favoritesList'); const count = document.getElementById('favCount'); if (count) count.textContent = String(favs.length); if (!list) return; if (!favs.length) { list.innerHTML = `
🕊️ 暂无收藏,点击「⭐ 收藏」保存当前图表或表格
`; return; } const MODE_LABEL = { chart: '📈 图表', table: '📋 表格', combine: '🖼️ 多图' }; list.innerHTML = favs.map(f => `
${escHtml(f.title)} ${MODE_LABEL[f.mode] || escHtml(f.mode)}
${escHtml(f.title)}
${f.width || '?'} × ${f.height || '?'}px · ${formatFavTime(f.createdAt)}
`).join(''); } function formatFavTime(iso) { if (!iso) return ''; const d = new Date(iso); const pad = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } // 编辑:新标签页打开主页面并加载该收藏配置 function editFavorite(id) { window.open('/?id=' + id, '_blank'); } function downloadFavorite(id) { const a = document.createElement('a'); a.href = '/api/favorites/' + id + '/image?download=1'; a.download = 'favorite_' + id + '.png'; document.body.appendChild(a); a.click(); a.remove(); } function deleteFavorite(id) { if (!confirm('确定删除这条收藏吗?')) return; fetch('/api/favorites/' + id, { method: 'DELETE' }) .then(res => res.json()) .then(data => { if (data && data.ok) { refreshFavorites(); showToast('🗑️ 已删除'); } else alert((data && data.error) || '删除失败'); }) .catch(err => alert(err.message)); } // 加载收藏配置(新标签页再次制作) function loadFavoriteForEdit(id) { fetch('/api/favorites/' + id) .then(res => res.json()) .then(data => { if (!data || !data.ok || !data.favorite) throw new Error('收藏不存在或已删除'); applyFavoriteConfig(data.favorite); showToast('✏️ 已载入收藏配置,可继续编辑制作'); }) .catch(err => showToast('❌ ' + err.message)); } function applyFavoriteConfig(fav) { const cfg = fav.config || {}; const mode = fav.mode || cfg.mode || 'chart'; switchMode(mode); if (mode === 'chart') { document.getElementById('dataInput').value = cfg.data || ''; document.getElementById('dataOrientation').value = cfg.rowsAsSeries ? 'rows' : 'columns'; document.getElementById('chartType').value = cfg.chartType || 'bar'; document.getElementById('chartTitle').value = cfg.title || ''; document.getElementById('themeStyle').value = cfg.theme || 'default'; document.getElementById('showLegend').checked = cfg.showLegend !== false; document.getElementById('showGrid').checked = cfg.showGrid !== false; document.getElementById('showLabel').checked = !!cfg.showLabel; document.getElementById('stackMode').checked = !!cfg.stackMode; document.getElementById('smoothLine').checked = cfg.smoothLine !== false; document.getElementById('dualAxis').checked = !!cfg.dualAxis; document.getElementById('leftAxisName').value = cfg.leftAxisName || ''; document.getElementById('rightAxisName').value = cfg.rightAxisName || ''; const lNameLoc = document.getElementById('leftAxisNameLoc'); const rNameLoc = document.getElementById('rightAxisNameLoc'); if (lNameLoc) lNameLoc.value = cfg.leftAxisNameLoc || 'vertical'; if (rNameLoc) rNameLoc.value = cfg.rightAxisNameLoc || 'vertical'; const lcMode = document.getElementById('labelColorMode'); const lcColor = document.getElementById('labelColor'); if (lcMode) lcMode.value = cfg.labelColorMode || 'auto'; if (lcColor) lcColor.value = cfg.labelColor || '#333333'; if (lcColor) lcColor.style.display = (cfg.labelColorMode || 'auto') === 'custom' ? 'block' : 'none'; document.getElementById('yAxisMin').value = cfg.yAxisMin || ''; document.getElementById('yAxisMax').value = cfg.yAxisMax || ''; document.getElementById('leftAxisMin').value = cfg.leftAxisMin || ''; document.getElementById('leftAxisMax').value = cfg.leftAxisMax || ''; document.getElementById('rightAxisMin').value = cfg.rightAxisMin || ''; document.getElementById('rightAxisMax').value = cfg.rightAxisMax || ''; document.getElementById('xAxisName').value = cfg.xAxisName || ''; document.getElementById('xAxisNameLoc').value = cfg.xAxisNameLoc || 'middle'; xAxisNameTouched = true; // 收藏恢复时尊重已存轴名,不再自动覆盖 // 刻度方向:命中 10° 档位用下拉,否则用自定义输入 const rotV = cfg.xLabelRotate || 0; const rotSel = document.getElementById('xLabelRotate'); const rotCustom = document.getElementById('xLabelRotateCustom'); if (rotV >= 0 && rotV <= 90 && rotV % 10 === 0) { rotSel.value = String(rotV); rotCustom.style.display = 'none'; } else { rotSel.value = 'custom'; rotCustom.value = rotV; rotCustom.style.display = 'block'; } document.getElementById('enableSplit').checked = !!cfg.enableSplit; document.getElementById('splitIndex').value = cfg.splitIndex || 3; document.getElementById('leftLabel').value = cfg.leftLabel || ''; document.getElementById('rightLabel').value = cfg.rightLabel || ''; document.getElementById('splitStyle').value = cfg.splitStyle || 'solid'; generateChart(); // 覆盖系列顺序/颜色/轴/独立类型 if (Array.isArray(cfg.seriesOrder)) seriesOrder = [...cfg.seriesOrder]; if (Array.isArray(cfg.seriesColors)) seriesColors = [...cfg.seriesColors]; if (Array.isArray(cfg.seriesAxis)) seriesAxis = [...cfg.seriesAxis]; window.seriesTypeOverrides = cfg.seriesTypeOverrides || {}; window.seriesStyleOverrides = cfg.seriesStyleOverrides || {}; renderSeriesConfig(); updateChart(); onDualAxisChange(); } else if (mode === 'table') { document.getElementById('dataInput').value = cfg.data || ''; document.getElementById('tableTitle').value = cfg.title || ''; document.getElementById('tableTheme').value = cfg.theme || 'default'; document.getElementById('tableFontSize').value = cfg.fontSize || 14; updateFontSize(); document.getElementById('stripeRows').checked = cfg.stripeRows !== false; document.getElementById('borderStyle').value = cfg.borderStyle || 'all'; generateTable(); } else if (mode === 'combine') { if (Array.isArray(cfg.charts) && cfg.charts.length) { combineCharts = cfg.charts.map(c => ({ ...defaultCombineChart(), ...c })); } document.getElementById('combineBigTitle').value = cfg.bigTitle || ''; document.getElementById('combineLegendMode').value = cfg.legendMode || 'own'; const dir = document.querySelector(`input[name="combineDirection"][value="${cfg.direction || 'horizontal'}"]`); if (dir) dir.checked = true; document.getElementById('gridCols').value = cfg.gridCols || 2; renderCombineCharts(); generateCombine(); onCombineGridToggle(); } } // ===== Toast ===== let toastTimer = null; function showToast(msg) { let t = document.getElementById('appToast'); if (!t) { t = document.createElement('div'); t.id = 'appToast'; document.body.appendChild(t); } t.textContent = msg; t.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 2200); }