Files
data-chart-tool/app.js
T
hz4th_coder bc8284d30f feat: 双Y轴图例放到各自轴上方靠两边对齐,不再占用两侧空白区 v1.18.0
- 主图表/多图合并/服务端API 三处统一:左轴图例横排靠左、右轴图例横排靠右,放在各自轴上方
- grid 左右不再为侧边图例让位,绘图区占满整图宽度
- 按实际宽度估算图例换行行数,grid 顶部相应让位避免重叠
2026-08-23 11:06:32 +08:00

2272 lines
91 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ===== 全局状态 =====
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
};
}
// 第一列是横坐标,其余列是系列
for (let i = 1; i < headers.length; i++) {
seriesData[headers[i]] = [];
}
for (let i = 1; i < rows.length; i++) {
categories.push(rows[i][0]);
for (let j = 1; j < rows[i].length && j < headers.length; j++) {
const val = parseFloat(rows[i][j]);
seriesData[headers[j]].push(isNaN(val) ? 0 : val);
}
}
return {
categories,
seriesNames: headers.slice(1),
seriesData
};
}
// ===== 生成图表 =====
function generateChart() {
const rawText = document.getElementById('dataInput').value;
if (!rawText.trim()) {
alert('请输入数据');
return;
}
parsedData = parseData(rawText, document.getElementById('dataOrientation').value === 'rows');
if (!parsedData) return;
// 初始化系列顺序和颜色
seriesOrder = parsedData.seriesNames.map((_, i) => i);
const palette = colorPalettes[document.getElementById('themeStyle').value] || colorPalettes.default;
seriesColors = parsedData.seriesNames.map((_, i) => palette[i % palette.length]);
seriesAxis = parsedData.seriesNames.map(() => 0);
// 渲染系列配置
renderSeriesConfig();
// 初始化图表
initChart();
updateChart();
refreshPreview();
}
// ===== 初始化图表实例 =====
function initChart() {
const chartDom = document.getElementById('chartArea');
chartDom.innerHTML = '';
if (chartInstance) {
chartInstance.dispose();
}
chartInstance = echarts.init(chartDom, null, { renderer: 'canvas' });
// 响应式
window.addEventListener('resize', () => {
chartInstance && chartInstance.resize();
});
}
// ===== 饼图/雷达图 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 xAxisName = document.getElementById('xAxisName').value.trim();
const xAxisNameLoc = document.getElementById('xAxisNameLoc').value;
const xLabelRotate = xLabelRotateValue();
// 饼图/雷达图:走专用构建逻辑(无坐标轴/网格,不支持双轴/堆叠/分割)
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) }
]);
}
}
// 数据标签
if (showLabel) {
seriesItem.label = {
show: true,
position: type === 'bar' ? 'top' : 'top',
fontSize: 11,
color: textColor,
formatter: (params) => {
if (params.value >= 10000) return (params.value / 10000).toFixed(1) + 'w';
if (params.value >= 1000) return (params.value / 1000).toFixed(1) + 'k';
return params.value;
}
};
}
// 双Y轴:按系列指定左右轴
if (dualAxis) {
seriesItem.yAxisIndex = (seriesAxis[origIdx] === 1) ? 1 : 0;
}
return seriesItem;
});
// 区域分割 - 使用 graphic 组件精确画分割线在柱子间隙中
const graphicElements = [];
if (enableSplit) {
const splitIdx = parseInt(document.getElementById('splitIndex').value) || 3;
const leftLabel = document.getElementById('leftLabel').value || '左侧';
const rightLabel = document.getElementById('rightLabel').value || '右侧';
const splitStyle = document.getElementById('splitStyle').value;
if (splitIdx < parsedData.categories.length) {
// 区域背景色用 markArea
const splitLineColor = theme === 'dark' ? '#ff6b6b' : '#e74c3c';
if (series.length > 0) {
series[0].markArea = {
silent: true,
data: [
[
{
name: leftLabel,
xAxis: parsedData.categories[0],
itemStyle: {
color: theme === 'dark' ? 'rgba(79,195,247,0.06)' : 'rgba(79,70,229,0.05)',
borderWidth: 0
},
label: {
show: true,
position: 'insideTop',
fontSize: 13,
fontWeight: 'bold',
color: theme === 'dark' ? '#4fc3f7' : '#4f46e5',
offset: [0, 10]
}
},
{ xAxis: parsedData.categories[splitIdx - 1] }
],
[
{
name: rightLabel,
xAxis: parsedData.categories[splitIdx],
itemStyle: {
color: theme === 'dark' ? 'rgba(255,107,107,0.06)' : 'rgba(239,68,68,0.05)',
borderWidth: 0
},
label: {
show: true,
position: 'insideTop',
fontSize: 13,
fontWeight: 'bold',
color: theme === 'dark' ? '#ff6b6b' : '#ef4444',
offset: [0, 10]
}
},
{ xAxis: parsedData.categories[parsedData.categories.length - 1] }
]
]
};
}
// 用 graphic 组件在渲染后画分割线(在柱子间隙中)
// 先存到全局,在 setOption 后通过 rendered 事件绘制
window._splitConfig = { splitIdx, splitLineColor, splitStyle, theme };
} else {
window._splitConfig = null;
}
} else {
window._splitConfig = null;
}
// 图表配置:双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 ? 50 : 16;
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
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, icon: 'roundRect'
},
{
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, icon: 'roundRect'
}
];
gridLeftCfg = '3%';
gridRightCfg = '4%';
dualGridTop = legendTop + legendH + 8;
} else {
legendConfig = showLegend ? {
show: true,
top: title ? 50 : 15,
textStyle: { color: textColor, fontSize: 12 },
itemGap: 20,
icon: 'roundRect'
} : { show: false };
gridLeftCfg = '3%';
gridRightCfg = '4%';
if (dualAxis) dualGridTop = title ? 52 : 24;
}
// 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 = `<div style="font-weight:600;margin-bottom:6px">${params[0].axisValue}</div>`;
params.forEach(p => {
html += `<div style="display:flex;align-items:center;gap:6px;margin:3px 0">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${p.color}"></span>
<span>${p.seriesName}:</span>
<span style="font-weight:600">${p.value.toLocaleString()}</span>
</div>`;
});
return html;
}
},
legend: 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 || '左轴',
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
splitLine: {
show: showGrid,
lineStyle: { color: theme === 'dark' ? '#333' : '#f0f0f0', type: 'dashed' }
}
},
{
type: 'value',
name: rightAxisName || '右轴',
position: 'right',
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 4, 0, 0] },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
splitLine: { show: false }
}
] : {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
splitLine: {
show: showGrid,
lineStyle: {
color: theme === 'dark' ? '#333' : '#f0f0f0',
type: 'dashed'
}
}
},
series: series,
animationDuration: 800,
animationEasing: 'cubicOut'
};
chartInstance.setOption(option, true);
// 绘制分割线(在 category 间隙中)
drawSplitLine();
refreshPreview();
}
// ===== 在柱子间隙中精确绘制分割线 =====
function drawSplitLine() {
if (!chartInstance || !window._splitConfig) {
if (chartInstance) {
chartInstance.setOption({ graphic: { elements: [] } });
}
return;
}
const { splitIdx, splitLineColor, splitStyle } = window._splitConfig;
// 使用 convertToPixel 获取 category 的像素坐标(x位置)
const leftX = chartInstance.convertToPixel({ xAxisIndex: 0 }, splitIdx - 1);
const rightX = chartInstance.convertToPixel({ xAxisIndex: 0 }, splitIdx);
const midX = (leftX + rightX) / 2;
// 使用内部 coordinateSystem 获取精确的绘图区域边界
// 这是ECharts内部稳定可用的接口
const gridRect = chartInstance.getModel()
.getComponent('grid', 0)
.coordinateSystem.getRect();
const yTop = gridRect.y;
const yBottom = gridRect.y + gridRect.height;
// 虚线样式
let lineDash = null;
if (splitStyle === 'dashed') lineDash = [8, 4];
else if (splitStyle === 'dotted') lineDash = [3, 3];
chartInstance.setOption({
graphic: {
elements: [
{
type: 'line',
shape: {
x1: midX,
y1: yTop,
x2: midX,
y2: yBottom
},
style: {
stroke: splitLineColor,
lineWidth: 2,
lineDash: lineDash
},
z: 100
}
]
}
});
}
// ===== 渲染系列配置 =====
function renderSeriesConfig() {
const container = document.getElementById('seriesConfig');
container.innerHTML = '';
seriesOrder.forEach((origIdx, displayIdx) => {
const name = parsedData.seriesNames[origIdx];
const color = seriesColors[origIdx];
const dualAxis = document.getElementById('dualAxis').checked;
const axisSelect = dualAxis ? `
<select onchange="updateSeriesAxis(${origIdx}, this.value)" title="所属坐标轴">
<option value="0" ${seriesAxis[origIdx] === 0 ? 'selected' : ''}>左轴</option>
<option value="1" ${seriesAxis[origIdx] === 1 ? 'selected' : ''}>右轴</option>
</select>
` : '';
const item = document.createElement('div');
item.className = 'series-item';
item.draggable = true;
item.dataset.index = displayIdx;
item.innerHTML = `
<span class="drag-handle">⠿</span>
<span class="series-name">${name}</span>
<input type="color" value="${color}" onchange="updateSeriesColor(${origIdx}, this.value)">
<select onchange="updateSeriesType(${origIdx}, this.value)">
<option value="auto">自动</option>
<option value="bar">柱状</option>
<option value="line">折线</option>
</select>
${axisSelect}
`;
// 拖拽事件
item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragover', handleDragOver);
item.addEventListener('drop', handleDrop);
item.addEventListener('dragend', handleDragEnd);
container.appendChild(item);
});
}
// ===== 拖拽排序 =====
let draggedItem = null;
function handleDragStart(e) {
draggedItem = this;
this.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = this.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
this.style.borderTopColor = 'var(--primary)';
this.style.borderTopWidth = '2px';
} else {
this.style.borderBottomColor = 'var(--primary)';
this.style.borderBottomWidth = '2px';
}
}
function handleDrop(e) {
e.preventDefault();
// 清除样式
document.querySelectorAll('.series-item').forEach(item => {
item.style.borderTopColor = '';
item.style.borderTopWidth = '';
item.style.borderBottomColor = '';
item.style.borderBottomWidth = '';
});
if (draggedItem === this) return;
const fromIdx = parseInt(draggedItem.dataset.index);
const toIdx = parseInt(this.dataset.index);
// 交换顺序
const temp = seriesOrder[fromIdx];
seriesOrder[fromIdx] = seriesOrder[toIdx];
seriesOrder[toIdx] = temp;
renderSeriesConfig();
updateChart();
}
function handleDragEnd() {
this.classList.remove('dragging');
document.querySelectorAll('.series-item').forEach(item => {
item.style.borderTopColor = '';
item.style.borderTopWidth = '';
item.style.borderBottomColor = '';
item.style.borderBottomWidth = '';
});
}
// ===== 系列操作 =====
function updateSeriesColor(origIdx, color) {
seriesColors[origIdx] = color;
updateChart();
}
function updateSeriesType(origIdx, type) {
// 存储自定义类型覆盖
if (!window.seriesTypeOverrides) window.seriesTypeOverrides = {};
window.seriesTypeOverrides[origIdx] = type;
updateChart();
}
// ===== 双Y轴 =====
function onDualAxisChange() {
const on = document.getElementById('dualAxis').checked;
document.getElementById('rightAxisGroup').style.display = on ? 'block' : 'none';
renderSeriesConfig(); // 显示/隐藏系列轴选择
updateChart();
}
function updateSeriesAxis(origIdx, axis) {
seriesAxis[origIdx] = parseInt(axis) || 0;
updateChart();
}
// ===== 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 adjustColor(hex, amount) {
hex = hex.replace('#', '');
const num = parseInt(hex, 16);
let r = Math.min(255, ((num >> 16) & 0xff) + amount);
let g = Math.min(255, ((num >> 8) & 0xff) + amount);
let b = Math.min(255, (num & 0xff) + amount);
return `rgb(${r},${g},${b})`;
}
function loadSampleData() {
// 循环切换示例
const currentIdx = sampleDataSets.findIndex(s => s.data === document.getElementById('dataInput').value);
const nextIdx = (currentIdx + 1) % sampleDataSets.length;
document.getElementById('dataInput').value = sampleDataSets[nextIdx].data;
generateChart();
}
function clearData() {
document.getElementById('dataInput').value = '';
document.getElementById('chartArea').innerHTML = `
<div class="placeholder">
<p>📊</p>
<p>输入数据后点击"生成图表"</p>
</div>
`;
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
parsedData = null;
document.getElementById('seriesConfig').innerHTML = '<p class="hint-text">生成图表后可在此调整各系列的顺序和颜色</p>';
}
// 数据方向切换(列=系列 / 行=系列):重新解析并重建系列配置
function 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 => `<option value="${h.key}">${h.w} × ${h.h}</option>`).join('')
: '<option value="">— 暂无历史 —</option>';
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 ? ` <span class="export-preview-size">${w} × ${h}</span>` : '';
title.innerHTML = `📐 导出预览${sizeTag}`;
inner.innerHTML = `<img src="${url}" alt="导出预览">`;
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 = '<div class="placeholder"><p>⏳</p><p>正在生成表格...</p></div>';
fetch('/api/table', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
})
.then(res => {
if (!res.ok) throw new Error('表格生成失败');
return res.blob();
})
.then(blob => {
tableImageBlob = blob;
const url = URL.createObjectURL(blob);
chartArea.innerHTML = `<div class="table-preview"><img src="${url}" alt="表格预览"></div>`;
refreshPreview();
})
.catch(err => {
chartArea.innerHTML = `<div class="placeholder"><p>❌</p><p>${err.message}</p></div>`;
});
}
// ===== 更新表格(配置变更时) =====
function updateTable() {
if (currentMode === 'table' && document.getElementById('dataInput').value.trim()) {
generateTable();
}
}
// ===== 统一导出图片 =====
function exportImage(format) {
if (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,
xAxisName = '', xAxisNameLoc = 'end', 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) }
]);
}
}
if (showLabel) {
s.label = {
show: true,
position: 'top',
fontSize: 11,
color: textColor,
formatter: (p) => {
if (p.value >= 10000) return (p.value / 10000).toFixed(1) + 'w';
if (p.value >= 1000) return (p.value / 1000).toFixed(1) + 'k';
return p.value;
}
};
}
return s;
});
// 双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 ? 42 : 12;
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
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, icon: 'roundRect'
},
{
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, icon: 'roundRect'
}
];
gridLeftCfg = '3%';
gridRightCfg = '4%';
dualGridTop = legendTop + legendH + 6;
} else {
legendCfg = showLegend ? {
show: true,
top: title ? 40 : 10,
textStyle: { color: textColor, fontSize: 12 },
itemGap: 20,
icon: 'roundRect'
} : { show: false };
gridLeftCfg = '3%';
gridRightCfg = '4%';
if (dualYAxis) dualGridTop = title ? 44 : 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 || '左轴',
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
splitLine: { show: showGrid, lineStyle: { color: theme === 'dark' ? '#333' : '#f0f0f0', type: 'dashed' } }
},
{
type: 'value',
name: rightAxisName || '右轴',
position: 'right',
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 4, 0, 0] },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
splitLine: { show: false }
}
] : {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 11 },
splitLine: { show: showGrid, lineStyle: { color: theme === 'dark' ? '#333' : '#f0f0f0', type: 'dashed' } }
},
series,
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: '', seriesConfig: [], xAxisName: '', xAxisNameLoc: 'end', 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function renderCombineCharts() {
const container = document.getElementById('combineChartsContainer');
if (!container) return;
container.innerHTML = combineCharts.map((c, i) => `
<div class="combine-chart-card" id="combineCard${i}">
<div class="combine-card-head">
<h3>📈 图${i + 1}</h3>
<button class="btn btn-remove" onclick="removeCombineChart(${i})" title="删除此图">✕</button>
</div>
<div class="config-group">
<label>标题</label>
<input type="text" value="${escHtml(c.title)}" placeholder="图${i + 1}标题" oninput="updateCombineChart(${i},'title',this.value)">
</div>
<div class="config-group">
<label>图表类型</label>
<select onchange="updateCombineChart(${i},'type',this.value)">
${COMBINE_TYPES.map(([v, l]) => `<option value="${v}" ${c.type === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
</div>
<div class="config-group">
<label>主题风格</label>
<select onchange="updateCombineChart(${i},'theme',this.value)">
${COMBINE_THEMES.map(([v, l]) => `<option value="${v}" ${c.theme === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
</div>
<div class="config-group">
<label>显示选项</label>
<div class="checkbox-group">
<label><input type="checkbox" ${c.legend ? 'checked' : ''} onchange="updateCombineChart(${i},'legend',this.checked)"> 图例</label>
<label><input type="checkbox" ${c.grid ? 'checked' : ''} onchange="updateCombineChart(${i},'grid',this.checked)"> 网格线</label>
<label><input type="checkbox" ${c.label ? 'checked' : ''} onchange="updateCombineChart(${i},'label',this.checked)"> 数据标签</label>
<label><input type="checkbox" ${c.stack ? 'checked' : ''} onchange="updateCombineChart(${i},'stack',this.checked)"> 堆叠</label>
<label><input type="checkbox" ${c.rowsAsSeries ? 'checked' : ''} onchange="updateCombineChart(${i},'rowsAsSeries',this.checked)"> 行=系列</label>
</div>
</div>
<div class="config-group">
<label><input type="checkbox" ${c.dualYAxis ? 'checked' : ''} onchange="updateCombineChart(${i},'dualYAxis',this.checked)"> 双Y轴(不同左右量度)</label>
</div>
${c.dualYAxis ? `
<div class="config-group">
<label>左右轴名称(量度可不同)</label>
<div class="res-input-row">
<input type="text" value="${escHtml(c.leftAxisName)}" placeholder="左轴,如:销售额" oninput="updateCombineChart(${i},'leftAxisName',this.value)">
<span class="res-times">/</span>
<input type="text" value="${escHtml(c.rightAxisName)}" placeholder="右轴,如:增长率(%)" oninput="updateCombineChart(${i},'rightAxisName',this.value)">
</div>
</div>` : ''}
<div class="config-group">
<label>X轴设置</label>
<div class="res-input-row" style="margin-bottom:8px">
<input type="text" value="${escHtml(c.xAxisName || '')}" placeholder="轴名,如:月份" oninput="updateCombineChart(${i},'xAxisName',this.value)" style="flex:2">
<select onchange="updateCombineChart(${i},'xAxisNameLoc',this.value)">
<option value="end" ${c.xAxisNameLoc === 'middle' ? '' : 'selected'}>右侧末端</option>
<option value="middle" ${c.xAxisNameLoc === 'middle' ? 'selected' : ''}>轴下方居中</option>
</select>
</div>
<div class="res-input-row">
<select id="combineRotateSel${i}" onchange="onCombineRotateSel(${i}, this.value)" style="flex:1" title="刻度文字方向">
${[0,10,20,30,40,50,60,70,80,90].map(v => `<option value="${v}" ${String(v) === combineRotateSelValue(c.xLabelRotate) ? 'selected' : ''}>${v}°</option>`).join('')}
<option value="custom" ${combineRotateSelValue(c.xLabelRotate) === 'custom' ? 'selected' : ''}>自定义…</option>
</select>
<input type="number" min="0" max="90" value="${c.xLabelRotate || 0}" id="combineRotateCustom${i}" style="width:58px;${combineRotateSelValue(c.xLabelRotate) === 'custom' ? '' : 'display:none'}" oninput="onCombineRotateCustom(${i}, this.value)" title="手动输入0-90°">
<span class="res-times">°</span>
</div>
</div>
<div class="config-group">
<label>系列图表类型 / 坐标轴</label>
<div class="combine-series-cfg" id="combineSeriesCfg${i}">${renderCombineSeriesConfigHTML(i)}</div>
</div>
<div class="config-group">
<label>数据</label>
<textarea rows="5" placeholder="图${i + 1}的CSV数据..." oninput="updateCombineChart(${i},'data',this.value)">${escHtml(c.data)}</textarea>
</div>
</div>
`).join('');
}
function updateCombineChart(i, field, value) {
if (!combineCharts[i]) return;
combineCharts[i][field] = value;
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);
}
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 };
});
}
function renderCombineSeriesConfigHTML(i) {
const c = combineCharts[i];
if (!c) return '<p class="hint-text">输入数据后自动显示各系列的图表类型/坐标轴</p>';
syncCombineSeriesConfig(i);
const p = parseData(c.data, !!c.rowsAsSeries);
if (!p || !p.seriesNames.length) return '<p class="hint-text">输入数据后自动显示各系列的图表类型/坐标轴</p>';
const dual = !!c.dualYAxis;
return p.seriesNames.map((name, idx) => {
const sc = (c.seriesConfig && c.seriesConfig[idx]) || { name, type: 'auto', axis: 0 };
return `<div class="combine-series-row">
<span class="combine-series-name" title="${escHtml(name)}">${escHtml(name)}</span>
<select onchange="updateCombineSeriesCfg(${i}, ${idx}, 'type', this.value)" title="图表类型">
<option value="auto" ${sc.type === 'auto' ? 'selected' : ''}>自动</option>
<option value="bar" ${sc.type === 'bar' ? 'selected' : ''}>柱状</option>
<option value="line" ${sc.type === 'line' ? 'selected' : ''}>折线</option>
</select>
${dual ? `<select onchange="updateCombineSeriesCfg(${i}, ${idx}, 'axis', this.value)" title="坐标轴">
<option value="0" ${String(sc.axis) === '0' ? 'selected' : ''}>左轴</option>
<option value="1" ${String(sc.axis) === '1' ? 'selected' : ''}>右轴</option>
</select>` : ''}
</div>`;
}).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 = '<div class="placeholder"><p>⏳</p><p>正在合并...</p></div>';
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 || '',
xAxisName: c.xAxisName || '', xAxisNameLoc: c.xAxisNameLoc || 'end', 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 = `<div class="table-preview"><img src="${url}" alt="合并图预览" style="max-width:100%;"></div>`;
refreshPreview();
}).catch(err => {
chartArea.innerHTML = `<div class="placeholder"><p>❌</p><p>${err.message}</p></div>`;
});
}
// 导出合并图(走统一导出)
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,
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 || {}
};
}
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 = `<div class="fav-empty">加载失败:${escHtml(err.message)}</div>`;
});
}
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 = `<div class="fav-empty">🕊️ 暂无收藏,点击「⭐ 收藏」保存当前图表或表格</div>`;
return;
}
const MODE_LABEL = { chart: '📈 图表', table: '📋 表格', combine: '🖼️ 多图' };
list.innerHTML = favs.map(f => `
<div class="fav-card">
<div class="fav-card-img">
<img src="/api/favorites/${f.id}/image" alt="${escHtml(f.title)}" loading="lazy"
onclick="window.open('/api/favorites/${f.id}/image','_blank')" title="点击查看原图">
<span class="fav-badge">${MODE_LABEL[f.mode] || escHtml(f.mode)}</span>
</div>
<div class="fav-card-body">
<div class="fav-card-title" title="${escHtml(f.title)}">${escHtml(f.title)}</div>
<div class="fav-card-meta">${f.width || '?'} × ${f.height || '?'}px · ${formatFavTime(f.createdAt)}</div>
<div class="fav-card-actions">
<button class="btn btn-primary btn-sm" onclick="editFavorite('${f.id}')" title="在新标签页再次制作">✏️ 编辑</button>
<button class="btn btn-secondary btn-sm" onclick="downloadFavorite('${f.id}')" title="下载原图">⬇️ 下载</button>
<button class="btn btn-remove btn-sm" onclick="deleteFavorite('${f.id}')" title="删除">🗑️</button>
</div>
</div>
</div>
`).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 || '';
document.getElementById('xAxisName').value = cfg.xAxisName || '';
document.getElementById('xAxisNameLoc').value = cfg.xAxisNameLoc || 'end';
// 刻度方向:命中 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 || {};
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);
}