Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a696daeb8 | ||
|
|
d9b3253fa0 | ||
|
|
51d3e09378 | ||
|
|
9a8cbb125c | ||
|
|
52a245e044 | ||
|
|
479d95cb2c |
@@ -45,6 +45,9 @@ Content-Type: application/json
|
||||
| `leftLabel` | string | — | `"左侧"` | 左侧区域标签 |
|
||||
| `rightLabel` | string | — | `"右侧"` | 右侧区域标签 |
|
||||
| `splitStyle` | string | — | `"solid"` | 分割线样式:`solid` / `dashed` / `dotted` |
|
||||
| `dualYAxis` | boolean | — | `false` | 启用双Y轴(左右量度不同) |
|
||||
| `rightAxisSeries` | array | — | `null` | 右轴系列名列表,如 `["利润"]`(未列出的系列用左轴) |
|
||||
| `rightAxisName` | string | — | `""` | 右轴名称 |
|
||||
| `width` | number | — | `800` | 图片宽度(px) |
|
||||
| `height` | number | — | `500` | 图片高度(px) |
|
||||
| `pixelRatio` | number | — | `2` | 像素倍率(越大越清晰) |
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- **柱状图** - 支持单系列/多系列对比
|
||||
- **折线图** - 支持平滑曲线、面积填充
|
||||
- **混合图** - 柱状图+折线图组合展示
|
||||
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,系列可指定左轴/右轴(含右轴名称)
|
||||
|
||||
### 📋 表格功能
|
||||
- **表格图片生成** - 根据数据生成精美的表格图片
|
||||
@@ -44,7 +45,11 @@
|
||||
- 返回 PNG 图片,支持自定义分辨率和像素倍率
|
||||
|
||||
### 📥 导出功能
|
||||
- Web UI 导出 PNG(2倍分辨率)
|
||||
- 导出设置:分辨率预设(原始/1920×1080/1280×720/1024×768/800×600/自定义)
|
||||
- 自定义分辨率手动输入,页面实时居中预览保存后的图
|
||||
- 分辨率历史记忆(最近5个,localStorage 持久化,一键复用)
|
||||
- 文件名默认按日期时间自动命名(每次不同),支持手动命名
|
||||
- Web UI 导出 PNG / 图表模式支持 SVG
|
||||
- API 直接返回 PNG 图片流
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
@@ -3,6 +3,7 @@ let chartInstance = null;
|
||||
let parsedData = null;
|
||||
let seriesColors = [];
|
||||
let seriesOrder = [];
|
||||
let seriesAxis = []; // 每个系列所属轴:0=左轴 1=右轴
|
||||
let currentMode = 'chart'; // 'chart' or 'table'
|
||||
let tableImageBlob = null;
|
||||
|
||||
@@ -60,13 +61,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('dataInput').value = sampleDataSets[0].data;
|
||||
generateChart();
|
||||
|
||||
// 预填双图合并示例
|
||||
// 预填双图合并示例(不提前生成,切到双图模式时由 switchMode 触发)
|
||||
document.getElementById('combineData1').value = sampleDataSets[0].data;
|
||||
document.getElementById('combineData2').value = sampleDataSets[1].data;
|
||||
document.getElementById('combineTitle1').value = '季度销售对比';
|
||||
document.getElementById('combineType2').value = 'line';
|
||||
document.getElementById('combineTitle2').value = '年度增长趋势';
|
||||
generateCombine();
|
||||
|
||||
// 初始化导出设置
|
||||
renderHistory();
|
||||
refreshExportHint();
|
||||
});
|
||||
|
||||
// ===== 数据解析 =====
|
||||
@@ -128,6 +132,7 @@ function generateChart() {
|
||||
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();
|
||||
@@ -135,6 +140,8 @@ function generateChart() {
|
||||
// 初始化图表
|
||||
initChart();
|
||||
updateChart();
|
||||
|
||||
refreshPreview();
|
||||
}
|
||||
|
||||
// ===== 初始化图表实例 =====
|
||||
@@ -167,6 +174,8 @@ function updateChart() {
|
||||
const stackMode = document.getElementById('stackMode').checked;
|
||||
const smoothLine = document.getElementById('smoothLine').checked;
|
||||
const enableSplit = document.getElementById('enableSplit').checked;
|
||||
const dualAxis = document.getElementById('dualAxis').checked;
|
||||
const rightAxisName = document.getElementById('rightAxisName').value;
|
||||
|
||||
// 显示/隐藏分割配置
|
||||
document.getElementById('splitConfig').style.display = enableSplit ? 'block' : 'none';
|
||||
@@ -243,6 +252,11 @@ function updateChart() {
|
||||
};
|
||||
}
|
||||
|
||||
// 双Y轴:按系列指定左右轴
|
||||
if (dualAxis) {
|
||||
seriesItem.yAxisIndex = (seriesAxis[origIdx] === 1) ? 1 : 0;
|
||||
}
|
||||
|
||||
return seriesItem;
|
||||
});
|
||||
|
||||
@@ -374,7 +388,30 @@ function updateChart() {
|
||||
},
|
||||
axisTick: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
yAxis: dualAxis ? [
|
||||
{
|
||||
type: 'value',
|
||||
name: '左轴',
|
||||
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: textColor, fontSize: 11 },
|
||||
splitLine: {
|
||||
show: showGrid,
|
||||
lineStyle: { color: theme === 'dark' ? '#333' : '#f0f0f0', type: 'dashed' }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: rightAxisName || '右轴',
|
||||
position: 'right',
|
||||
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 4, 0, 0] },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: textColor, fontSize: 11 },
|
||||
splitLine: { show: false }
|
||||
}
|
||||
] : {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
@@ -396,6 +433,8 @@ function updateChart() {
|
||||
|
||||
// 绘制分割线(在 category 间隙中)
|
||||
drawSplitLine();
|
||||
|
||||
refreshPreview();
|
||||
}
|
||||
|
||||
// ===== 在柱子间隙中精确绘制分割线 =====
|
||||
@@ -459,6 +498,13 @@ function renderSeriesConfig() {
|
||||
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';
|
||||
@@ -474,6 +520,7 @@ function renderSeriesConfig() {
|
||||
<option value="bar">柱状</option>
|
||||
<option value="line">折线</option>
|
||||
</select>
|
||||
${axisSelect}
|
||||
`;
|
||||
|
||||
// 拖拽事件
|
||||
@@ -559,6 +606,19 @@ function updateSeriesType(origIdx, type) {
|
||||
updateChart();
|
||||
}
|
||||
|
||||
// ===== 双Y轴 =====
|
||||
function onDualAxisChange() {
|
||||
const on = document.getElementById('dualAxis').checked;
|
||||
document.getElementById('rightAxisGroup').style.display = on ? 'block' : 'none';
|
||||
renderSeriesConfig(); // 显示/隐藏系列轴选择
|
||||
updateChart();
|
||||
}
|
||||
|
||||
function updateSeriesAxis(origIdx, axis) {
|
||||
seriesAxis[origIdx] = parseInt(axis) || 0;
|
||||
updateChart();
|
||||
}
|
||||
|
||||
// ===== 工具函数 =====
|
||||
function adjustColor(hex, amount) {
|
||||
hex = hex.replace('#', '');
|
||||
@@ -593,37 +653,229 @@ function clearData() {
|
||||
document.getElementById('seriesConfig').innerHTML = '<p class="hint-text">生成图表后可在此调整各系列的顺序和颜色</p>';
|
||||
}
|
||||
|
||||
// ===== 导出图表 =====
|
||||
function exportChart(format) {
|
||||
// ===== 导出图表(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();
|
||||
}
|
||||
|
||||
if (format === 'png') {
|
||||
const url = chartInstance.getDataURL({
|
||||
type: 'png',
|
||||
pixelRatio: 2,
|
||||
backgroundColor: document.getElementById('themeStyle').value === 'dark' ? '#1a1a2e' : '#fff'
|
||||
});
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'chart.png';
|
||||
a.click();
|
||||
} else if (format === 'svg') {
|
||||
// 重新用 SVG 渲染
|
||||
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 = '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'; });
|
||||
}
|
||||
|
||||
// ===== 模式切换 =====
|
||||
@@ -711,6 +963,7 @@ function generateTable() {
|
||||
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>`;
|
||||
@@ -726,28 +979,21 @@ function updateTable() {
|
||||
|
||||
// ===== 统一导出图片 =====
|
||||
function exportImage(format) {
|
||||
if (currentMode === 'chart') {
|
||||
exportChart(format);
|
||||
} else if (currentMode === 'table') {
|
||||
exportTable(format);
|
||||
} else {
|
||||
exportCombine();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 导出表格 =====
|
||||
function exportTable(format) {
|
||||
if (!tableImageBlob) {
|
||||
alert('请先生成表格');
|
||||
if (format === 'svg') {
|
||||
if (currentMode !== 'chart') {
|
||||
alert('SVG 仅图表模式支持');
|
||||
return;
|
||||
}
|
||||
exportChartSvg();
|
||||
return;
|
||||
}
|
||||
const prefix = currentMode === 'chart' ? 'chart' : currentMode === 'table' ? 'table' : 'combine';
|
||||
exportCurrent(prefix);
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(tableImageBlob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'table.png';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
// ===== 导出表格(走统一导出) =====
|
||||
function exportTable(format) {
|
||||
exportCurrent('table');
|
||||
}
|
||||
|
||||
// ===== 快速切换风格 =====
|
||||
@@ -941,11 +1187,18 @@ function generateCombine() {
|
||||
const chart = echarts.init(holder, null, { renderer: 'canvas' });
|
||||
chart.setOption(option);
|
||||
setTimeout(() => {
|
||||
const cvs = holder.querySelector('canvas');
|
||||
if (!cvs) { reject(new Error('子图渲染失败')); return; }
|
||||
resolve(cvs);
|
||||
const srcCvs = holder.querySelector('canvas');
|
||||
if (!srcCvs) { reject(new Error('子图渲染失败')); return; }
|
||||
// 关键:先把内容复制到独立 canvas 再 dispose。
|
||||
// 直接 resolve 原 canvas 的话,Promise 微任务在 chart.dispose() 之后才执行,
|
||||
// 此时 canvas 内容已被 echarts 清空,合并出来就是白图。
|
||||
const copy = document.createElement('canvas');
|
||||
copy.width = srcCvs.width;
|
||||
copy.height = srcCvs.height;
|
||||
copy.getContext('2d').drawImage(srcCvs, 0, 0);
|
||||
chart.dispose();
|
||||
holder.remove();
|
||||
resolve(copy);
|
||||
}, 80);
|
||||
});
|
||||
|
||||
@@ -989,19 +1242,13 @@ function generateCombine() {
|
||||
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() {
|
||||
if (!combinedCanvas) {
|
||||
alert('请先生成合并图');
|
||||
return;
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = combinedCanvas.toDataURL('image/png');
|
||||
a.download = 'combine.png';
|
||||
a.click();
|
||||
exportCurrent('combine');
|
||||
}
|
||||
+63
-6
@@ -100,8 +100,15 @@ C, 20, 30, 40</pre>
|
||||
<label><input type="checkbox" id="showLabel" onchange="updateChart()"> 数据标签</label>
|
||||
<label><input type="checkbox" id="stackMode" onchange="updateChart()"> 堆叠模式</label>
|
||||
<label><input type="checkbox" id="smoothLine" checked onchange="updateChart()"> 平滑曲线</label>
|
||||
<label><input type="checkbox" id="dualAxis" onchange="onDualAxisChange()"> 双Y轴</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-group" id="rightAxisGroup" style="display:none;">
|
||||
<label>右轴名称(量度不同)</label>
|
||||
<input type="text" id="rightAxisName" placeholder="如:利润(万元)" oninput="updateChart()">
|
||||
<p class="hint-text">在下方「系列配置」中可指定每个系列用左轴/右轴</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格配置区 -->
|
||||
@@ -208,11 +215,56 @@ C, 20, 30, 40</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导出按钮 -->
|
||||
<!-- 导出设置 + 按钮(默认折叠) -->
|
||||
<div class="panel-section" id="exportSection">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-success" onclick="exportImage('png')">📥 导出PNG</button>
|
||||
<button class="btn btn-success" id="btnExportSvg" onclick="exportImage('svg')">📥 导出SVG</button>
|
||||
<div class="export-header">
|
||||
<div class="export-header-main" onclick="toggleExportSettings()" title="点击展开/折叠">
|
||||
<h2>📤 导出设置</h2>
|
||||
<span class="export-summary" id="exportSummary">原始大小</span>
|
||||
<span class="export-toggle" id="exportToggle">▸</span>
|
||||
</div>
|
||||
<button class="btn btn-success btn-sm" onclick="exportImage('png')">📥 下载</button>
|
||||
</div>
|
||||
|
||||
<div id="exportSettingsBody" class="export-settings-body" style="display:none;">
|
||||
<div class="config-group">
|
||||
<label>分辨率</label>
|
||||
<select id="exportPreset" onchange="onExportPresetChange()">
|
||||
<option value="original">原始大小</option>
|
||||
<option value="1920x1080">1920 × 1080</option>
|
||||
<option value="1280x720">1280 × 720</option>
|
||||
<option value="1024x768">1024 × 768</option>
|
||||
<option value="800x600">800 × 600</option>
|
||||
<option value="custom">自定义</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="config-group" id="customResGroup" style="display:none;">
|
||||
<label>自定义分辨率(宽 × 高)</label>
|
||||
<div class="res-input-row">
|
||||
<input type="number" id="customWidth" min="1" max="8192" placeholder="宽" oninput="onCustomResInput()">
|
||||
<span class="res-times">×</span>
|
||||
<input type="number" id="customHeight" min="1" max="8192" placeholder="高" oninput="onCustomResInput()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-group">
|
||||
<label>历史分辨率(最近5个)</label>
|
||||
<select id="exportHistory" onchange="onHistorySelect()">
|
||||
<option value="">— 暂无历史 —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="config-group">
|
||||
<label>文件名</label>
|
||||
<input type="text" id="exportFilename" placeholder="留空则按日期时间自动命名">
|
||||
<p class="hint-text" id="exportResHint"></p>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary" onclick="resetExportSettings()">🔄 恢复默认</button>
|
||||
<button class="btn btn-success" id="btnExportSvg" onclick="exportImage('svg')">📥 导出SVG</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -342,12 +394,12 @@ C, 20, 30, 40</pre>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="generateCombine()">🖼️ 生成合并图</button>
|
||||
<button class="btn btn-success" onclick="exportCombine()">📥 导出PNG</button>
|
||||
<button class="btn btn-success" onclick="exportImage('png')">📥 下载图片</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧面板:图表预览 + 风格切换 -->
|
||||
<!-- 右侧面板:图表预览 + 导出预览 + 风格切换 -->
|
||||
<div class="right-panel">
|
||||
<div class="chart-container">
|
||||
<div id="chartArea" class="chart-area">
|
||||
@@ -357,6 +409,11 @@ C, 20, 30, 40</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 导出效果预览(按分辨率居中展示保存后的图) -->
|
||||
<div class="export-preview-box" id="exportPreviewBox" style="display:none;">
|
||||
<div class="export-preview-title" id="exportPreviewTitle">📐 导出预览</div>
|
||||
<div class="export-preview-inner" id="exportPreviewInner"></div>
|
||||
</div>
|
||||
<!-- 风格快速切换按钮(放在图表右侧) -->
|
||||
<div class="theme-buttons-section">
|
||||
<h3>🎨 风格</h3>
|
||||
|
||||
@@ -113,7 +113,10 @@ function buildChartOption(params) {
|
||||
leftLabel = '左侧',
|
||||
rightLabel = '右侧',
|
||||
splitStyle = 'solid',
|
||||
seriesColors: customColors = null
|
||||
seriesColors: customColors = null,
|
||||
dualYAxis = false,
|
||||
rightAxisSeries = null,
|
||||
rightAxisName = ''
|
||||
} = params;
|
||||
|
||||
const parsedData = parseData(data);
|
||||
@@ -148,6 +151,12 @@ function buildChartOption(params) {
|
||||
}
|
||||
};
|
||||
|
||||
// 双Y轴:rightAxisSeries 指定右轴系列名
|
||||
if (dualYAxis) {
|
||||
const rightNames = Array.isArray(rightAxisSeries) ? rightAxisSeries : (rightAxisSeries ? [rightAxisSeries] : []);
|
||||
seriesItem.yAxisIndex = rightNames.includes(name) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (stackMode) {
|
||||
seriesItem.stack = 'total';
|
||||
}
|
||||
@@ -159,8 +168,7 @@ function buildChartOption(params) {
|
||||
seriesItem.areaStyle = theme === 'gradient' ? { opacity: 0.15 } : undefined;
|
||||
}
|
||||
|
||||
if (type === 'bar') {
|
||||
seriesItem.barMaxWidth = 40;
|
||||
if (type === 'bar') {seriesItem.barMaxWidth = 40;
|
||||
seriesItem.itemStyle.borderRadius = stackMode ? [0, 0, 0, 0] : [4, 4, 0, 0];
|
||||
}
|
||||
|
||||
@@ -269,7 +277,33 @@ function buildChartOption(params) {
|
||||
},
|
||||
axisTick: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
yAxis: dualYAxis ? [
|
||||
{
|
||||
type: 'value',
|
||||
name: '左轴',
|
||||
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 0, 0, 4] },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: textColor, fontSize: 11 },
|
||||
splitLine: {
|
||||
show: showGrid,
|
||||
lineStyle: {
|
||||
color: theme === 'dark' ? '#333' : '#f0f0f0',
|
||||
type: 'dashed'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: rightAxisName || '右轴',
|
||||
position: 'right',
|
||||
nameTextStyle: { color: textColor, fontSize: 11, padding: [0, 4, 0, 0] },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: textColor, fontSize: 11 },
|
||||
splitLine: { show: false }
|
||||
}
|
||||
] : {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
@@ -366,6 +400,9 @@ app.get('/api/chart', (req, res) => {
|
||||
leftLabel: req.query.leftLabel || '左侧',
|
||||
rightLabel: req.query.rightLabel || '右侧',
|
||||
splitStyle: req.query.splitStyle || 'solid',
|
||||
dualYAxis: req.query.dualYAxis === 'true',
|
||||
rightAxisSeries: req.query.rightAxisSeries ? req.query.rightAxisSeries.split(',') : null,
|
||||
rightAxisName: req.query.rightAxisName || '',
|
||||
width: parseInt(req.query.width) || 800,
|
||||
height: parseInt(req.query.height) || 500,
|
||||
format: req.query.format || 'png',
|
||||
@@ -882,7 +919,7 @@ app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'data-chart-tool',
|
||||
version: '1.8.0',
|
||||
version: '1.11.0',
|
||||
endpoints: {
|
||||
'POST /api/chart': '生成图表图片(JSON body)',
|
||||
'GET /api/chart': '生成图表图片(URL 参数)',
|
||||
@@ -897,7 +934,7 @@ app.get('/api/health', (req, res) => {
|
||||
app.get('/api/docs', (req, res) => {
|
||||
res.json({
|
||||
name: '数据可视化图表生成器 API',
|
||||
version: '1.8.0',
|
||||
version: '1.11.0',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -922,7 +959,10 @@ app.get('/api/docs', (req, res) => {
|
||||
width: { type: 'number', default: 800, description: '图片宽度(px)' },
|
||||
height: { type: 'number', default: 500, description: '图片高度(px)' },
|
||||
format: { type: 'string', default: 'png', options: ['png'], description: '输出格式' },
|
||||
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' }
|
||||
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' },
|
||||
dualYAxis: { type: 'boolean', default: false, description: '是否启用双Y轴(左右量度不同)' },
|
||||
rightAxisSeries: { type: 'array', default: null, description: '右轴系列名列表(如 ["利润"],指定哪些系列用右轴)' },
|
||||
rightAxisName: { type: 'string', default: '', description: '右轴名称' }
|
||||
},
|
||||
returns: 'image/png',
|
||||
example: {
|
||||
|
||||
@@ -607,3 +607,126 @@ input[type="range"]::-moz-range-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ===== 导出设置 ===== */
|
||||
.res-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.res-input-row input[type="number"] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.res-times {
|
||||
color: var(--text-light);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== 导出预览(按分辨率居中展示保存后的图) ===== */
|
||||
.export-preview-box {
|
||||
margin-top: 14px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.export-preview-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.export-preview-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
background: repeating-conic-gradient(#f1f5f9 0% 25%, #ffffff 0% 50%) 0 0 / 20px 20px;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.export-preview-inner img {
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,0.12);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.export-preview-size {
|
||||
font-size: 0.78rem;
|
||||
color: var(--primary);
|
||||
background: #eef2ff;
|
||||
border-radius: 4px;
|
||||
padding: 1px 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== 导出设置折叠栏 ===== */
|
||||
.export-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.export-header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.export-header h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.export-summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--primary);
|
||||
background: #eef2ff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.export-toggle {
|
||||
color: var(--text-light);
|
||||
font-size: 0.8rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.export-settings-body {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* 导出设置折叠栏下载按钮(更小,避免挤占) */
|
||||
.export-header .btn-sm {
|
||||
padding: 3px 8px;
|
||||
font-size: 0.74rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
Reference in New Issue
Block a user