feat: 增加图片合并功能

This commit is contained in:
R524809
2026-09-09 15:31:05 +08:00
parent bab02e1efd
commit 2a38778fa3
57 changed files with 684 additions and 50 deletions
+121
View File
@@ -1,17 +1,25 @@
import { loadCSV } from './csv.js';
import { getChartBuilder } from './charts/index.js';
import { downloadCanvas, mergeImages } from './merge.js';
import {
createAppShell,
renderAnchors,
renderCards,
renderCartBar,
renderCompanies,
renderTabs,
showChartError,
showMergePreview,
} from './ui.js';
const instances = new Map();
const activeViews = new Map();
const chartRenderVersions = new Map();
// 合并队列:instanceId → { instanceId, title, filename, dataUrl }
// dataUrl 是加购(及后续每次重渲染)时的快照,切换 tab 后实例被 dispose 也能照常导出
const cart = new Map();
let cartMode = 'pairs';
let closePreview;
let manifest;
let shell;
let state;
@@ -123,6 +131,7 @@ async function renderChart(chart, version, chartVersion) {
instances.set(chart.instanceId, instance);
const { width, height } = el.getBoundingClientRect();
instance.setOption(getChartBuilder(chart.key)(rows, { width, height, view }), true);
captureSnapshot(chart);
} catch (error) {
if (version === renderVersion) showChartError(chart.instanceId, error.message);
console.error(`渲染图表失败:${chart.id}`, error);
@@ -157,6 +166,116 @@ function exportPNG(chartId, filename) {
anchor.click();
}
/** 图表在合并队列中时,每次重渲染(切换视图、重载数据、resize)都刷新快照,保证所见即所得 */
function captureSnapshot(chart) {
if (!cart.has(chart.instanceId)) return;
const instance = instances.get(chart.instanceId);
if (!instance) return;
cart.get(chart.instanceId).dataUrl = instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' });
}
function toggleCart(chart) {
if (cart.delete(chart.instanceId)) {
updateCartButton(chart.instanceId);
} else {
const instance = instances.get(chart.instanceId);
if (!instance) return;
cart.set(chart.instanceId, {
instanceId: chart.instanceId,
title: chart.title,
filename: chart.filename || chart.title,
dataUrl: instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' }),
});
updateCartButton(chart.instanceId);
}
renderCart();
}
function updateCartButton(instanceId) {
const btn = document.querySelector(`[data-cart-id="${instanceId}"]`);
if (!btn) return;
const inCart = cart.has(instanceId);
btn.dataset.inCart = String(inCart);
btn.textContent = inCart ? '已加入 ✓' : '➕ 加入合并';
}
/** Map 保持插入序,删除后按新顺序重建即完成上移/下移 */
function moveCartItem(instanceId, delta) {
const entries = [...cart.entries()];
const from = entries.findIndex(([id]) => id === instanceId);
const to = from + delta;
if (from < 0 || to < 0 || to >= entries.length) return;
[entries[from], entries[to]] = [entries[to], entries[from]];
cart.clear();
entries.forEach(([id, item]) => cart.set(id, item));
renderCart();
}
function clipName(name, max = 24) {
return name.length > max ? `${name.slice(0, max)}…` : name;
}
/** 按当前模式把队列合成为一组 { canvas, filename, partial? }:pairs 两两合并,single 全部拼一张 */
async function buildCompositions() {
const items = [...cart.values()];
if (!items.length) return [];
if (cartMode === 'single') {
return [{
canvas: await mergeImages(items.map((item) => item.dataUrl)),
filename: `${clipName(items[0].filename)}等${items.length}张合并`,
}];
}
const pairs = [];
for (let i = 0; i < items.length; i += 2) {
const pair = items.slice(i, i + 2);
pairs.push({
canvas: await mergeImages(pair.map((item) => item.dataUrl)),
filename: pair.map((item) => clipName(item.filename)).join('+'),
partial: pair.length === 1,
});
}
return pairs;
}
async function openMergePreview() {
const entries = await buildCompositions();
if (!entries.length) return;
const notice = entries.some((entry) => entry.partial) ? '队列为奇数张,最后一张单独导出' : '';
const download = (entry) => downloadCanvas(entry.canvas, entry.filename);
closePreview = showMergePreview(entries, {
notice,
onDownload: download,
onDownloadAll: () => entries.forEach((entry, index) => {
setTimeout(() => download(entry), index * 350);
}),
onClose: () => closePreview?.(),
});
}
function renderCart() {
renderCartBar(shell.cartBar, {
items: [...cart.values()],
mode: cartMode,
onModeChange: (mode) => {
cartMode = mode;
renderCart();
},
onMove: moveCartItem,
onRemove: (instanceId) => {
cart.delete(instanceId);
updateCartButton(instanceId);
renderCart();
},
onClear: () => {
const ids = [...cart.keys()];
cart.clear();
ids.forEach(updateCartButton);
renderCart();
},
onPreview: openMergePreview,
});
}
function updateHash(nextState) {
const hash = serializeHash(nextState);
if (window.location.hash === hash) {
@@ -189,6 +308,8 @@ function renderCurrent() {
});
renderCards(shell.cards, charts, activeViews, {
onExport: exportPNG,
isCarted: (instanceId) => cart.has(instanceId),
onToggleCart: toggleCart,
onSelectView: (chart, view) => {
if (activeViews.get(chart.viewKey) === view) return;
activeViews.set(chart.viewKey, view);