// ===== 全局状态 =====
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 = ``;
});
}
// ===== 更新表格(配置变更时) =====
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 ? `
` : ''}
${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 = ``;
});
}
// 导出合并图(走统一导出)
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 => `
${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);
}