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; let renderVersion = 0; let cacheToken = Date.now(); function validateManifest(value) { if (!Array.isArray(value?.companies) || value.companies.length === 0) { throw new Error('manifest 缺少 companies'); } value.companies.forEach((company) => { if (!company.id || !Array.isArray(company.tabs) || !company.chartCatalog) { throw new Error('manifest 中存在无效公司配置'); } Object.entries(company.chartCatalog).forEach(([chartId, chart]) => { if (!chartId || !getChartBuilder(chart.key)) { throw new Error(`图表 ${chartId || '(空)'} 引用了未注册 key:${chart.key}`); } if (Boolean(chart.csv) === Boolean(chart.views)) { throw new Error(`图表 ${chartId} 必须声明 csv 或 views 之一`); } if (chart.views && (!chart.defaultView || !chart.views[chart.defaultView])) { throw new Error(`图表 ${chartId} 的 defaultView 无效`); } }); const tabIds = new Set(); company.tabs.forEach((tab) => { if (!tab.id || tabIds.has(tab.id)) throw new Error(`公司 ${company.id} 的 tab id 重复或为空`); if (!Array.isArray(tab.chartRefs)) throw new Error(`公司 ${company.id} 的 ${tab.id} 缺少 chartRefs`); tabIds.add(tab.id); const refs = new Set(); tab.chartRefs.forEach((chartId) => { if (!company.chartCatalog[chartId]) throw new Error(`栏目 ${tab.id} 引用了不存在的图表:${chartId}`); if (refs.has(chartId)) throw new Error(`栏目 ${tab.id} 重复引用图表:${chartId}`); refs.add(chartId); }); }); }); } function getCompany(companyId) { return manifest.companies.find((company) => company.id === companyId) || manifest.companies[0]; } function getTab(company, tabId) { return company.tabs.find((tab) => tab.id === tabId) || company.tabs[0]; } function chartsForTab(company, tab) { return tab.chartRefs.map((chartId) => ({ ...company.chartCatalog[chartId], id: chartId, instanceId: `${company.id}--${tab.id}--${chartId}`, viewKey: `${company.id}--${chartId}`, })); } function parseHash() { const [path = '', query = ''] = window.location.hash.replace(/^#\/?/, '').split('?'); const [companyId, tabId, chartId] = path.split('/').filter(Boolean).map(decodeURIComponent); const params = new URLSearchParams(query); return { companyId, tabId, chartId, view: params.get('view') || undefined }; } function normalizeState(nextState) { const company = getCompany(nextState.companyId); const tab = getTab(company, nextState.tabId); const chart = chartsForTab(company, tab).find((item) => item.id === nextState.chartId); const view = chart?.views && chart.views[nextState.view] ? nextState.view : chart?.defaultView; if (chart?.views && view) activeViews.set(chart.viewKey, view); return { companyId: company.id, tabId: tab.id, chartId: chart?.id, view }; } function serializeHash(nextState) { const path = [nextState.companyId, nextState.tabId, nextState.chartId] .filter(Boolean) .map(encodeURIComponent) .join('/'); return `#/${path}${nextState.view ? `?view=${encodeURIComponent(nextState.view)}` : ''}`; } function disposeAll() { instances.forEach((chart) => chart.dispose()); instances.clear(); } function chartSource(chart) { if (!chart.views) return chart.csv; const view = activeViews.get(chart.viewKey) || chart.defaultView; return chart.views[view]; } async function renderChart(chart, version, chartVersion) { const el = document.getElementById(`chart-${chart.instanceId}`); if (!el) return; const source = chartSource(chart); const view = chart.views ? activeViews.get(chart.viewKey) || chart.defaultView : undefined; try { const rows = await loadCSV(`${source}?t=${cacheToken}`); if (version !== renderVersion || chartVersion !== chartRenderVersions.get(chart.instanceId) || !document.body.contains(el)) return; const instance = echarts.init(el, null, { renderer: 'canvas' }); 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); } } function renderSingleChart(chart) { const previous = instances.get(chart.instanceId); previous?.dispose(); instances.delete(chart.instanceId); const chartVersion = (chartRenderVersions.get(chart.instanceId) || 0) + 1; chartRenderVersions.set(chart.instanceId, chartVersion); renderChart(chart, renderVersion, chartVersion); } function updateViewControls(chart, view) { const card = document.getElementById(`card-${chart.instanceId}`); if (!card) return; card.querySelectorAll('.view-button').forEach((button) => { button.dataset.active = String(button.dataset.view === view); }); const source = card.querySelector('.src'); if (source) source.textContent = chartSource(chart); } function exportPNG(chartId, filename) { const instance = instances.get(chartId); if (!instance) return; const anchor = document.createElement('a'); anchor.href = instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' }); anchor.download = `${filename}.png`; 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) { state = normalizeState(nextState); renderCurrent(); return; } window.location.hash = hash; } function selectAnchor(chart) { state = { ...state, chartId: chart.id, view: activeViews.get(chart.viewKey) }; history.replaceState(null, '', serializeHash(state)); document.getElementById(`card-${chart.instanceId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function renderCurrent() { state = normalizeState(state); const company = getCompany(state.companyId); const tab = getTab(company, state.tabId); const charts = chartsForTab(company, tab); const version = ++renderVersion; disposeAll(); renderCompanies(shell.companyNav, manifest.companies, company.id, (companyId) => { updateHash({ companyId }); }); renderTabs(shell.tabs, company.tabs, tab.id, (tabId) => { updateHash({ companyId: company.id, tabId }); }); 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); state = normalizeState({ companyId: company.id, tabId: tab.id, chartId: chart.id, view }); history.pushState(null, '', serializeHash(state)); updateViewControls(chart, view); renderSingleChart(chart); }, }); renderAnchors(shell.chartNav, charts, selectAnchor); charts.forEach((chart) => { chartRenderVersions.set(chart.instanceId, (chartRenderVersions.get(chart.instanceId) || 0) + 1); }); Promise.all(charts.map((chart) => renderChart(chart, version, chartRenderVersions.get(chart.instanceId)))); } async function start() { try { const response = await fetch('manifest.json'); if (!response.ok) throw new Error(`读取 manifest 失败:${response.status}`); manifest = await response.json(); validateManifest(manifest); shell = createAppShell(document.getElementById('app'), { onReload: () => { cacheToken = Date.now(); renderCurrent(); }, }); state = normalizeState(parseHash()); if (!window.location.hash) history.replaceState(null, '', serializeHash(state)); renderCurrent(); } catch (error) { document.getElementById('app').textContent = `初始化失败:${error.message}`; console.error(error); } } document.addEventListener('DOMContentLoaded', start); window.addEventListener('hashchange', () => { if (!manifest) return; state = normalizeState(parseHash()); renderCurrent(); }); let resizeTimer; window.addEventListener('resize', () => { if (!shell) return; clearTimeout(resizeTimer); resizeTimer = setTimeout(renderCurrent, 200); });