- 主图表/多图合并/服务端API 三处统一:左轴图例横排靠左、右轴图例横排靠右,放在各自轴上方 - grid 左右不再为侧边图例让位,绘图区占满整图宽度 - 按实际宽度估算图例换行行数,grid 顶部相应让位避免重叠
2272 lines
91 KiB
JavaScript
2272 lines
91 KiB
JavaScript
// ===== 全局状态 =====
|
||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
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);
|
||
}
|