import { loadCSV } from './csv.js'; import { getChartBuilder } from './charts/index.js'; import { downloadCanvas, mergeImages } from './merge.js'; import { createAppShell, renderAnchors, renderCards, renderCartBar, renderCompanies, renderTabControls, renderTabs, showChartError, showMergePreview, } from './ui.js'; const instances = new Map(); const activeViews = new Map(); const chartRenderVersions = new Map(); // tab 级参数(现价等):`${companyId}/${tabId}` → { price: 342.5 } // 存进 Map 而不是 DOM,是为了切 tab / 改窗口尺寸重渲染后还能带着值回来 const tabParams = new Map(); // CSV 缓存:source → Promise。缓存 Promise,同一 tab 的多张图共用一次请求, // 手输现价连续重渲染也不会重复拉数据;「重新加载数据」换 cacheToken 即整体失效。 const rowCache = 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(); let paramTimer; 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); validateControls(company.id, tab); 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); }); }); }); } /** tab.controls 是图表参数的声明处(现价等),这里挡住会导致输入框静默失效的配置 */ function validateControls(companyId, tab) { const seen = new Set(); (tab.controls ?? []).forEach((control) => { const where = `公司 ${companyId} 的 ${tab.id} 控件 ${control.id || '(空)'}`; if (!control.id || seen.has(control.id)) throw new Error(`${where} 的 id 重复或为空`); seen.add(control.id); if (control.type !== 'number') throw new Error(`${where} 的 type 不受支持:${control.type}`); ['value', 'min', 'max'].forEach((field) => { if (!Number.isFinite(control[field])) throw new Error(`${where} 的 ${field} 必须是数字`); }); if (control.min > control.max) throw new Error(`${where} 的 min > max`); if (control.value < control.min || control.value > control.max) { throw new Error(`${where} 的默认值 ${control.value} 不在 ${control.min}–${control.max} 内`); } }); } 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]; } /** tab 参数:首次访问时用 manifest 的默认值播种 */ function paramValues(company, tab) { const key = `${company.id}/${tab.id}`; if (!tabParams.has(key)) { tabParams.set(key, Object.fromEntries((tab.controls ?? []).map((control) => [control.id, control.value]))); } return tabParams.get(key); } function chartsForTab(company, tab) { const params = paramValues(company, tab); return tab.chartRefs.map((chartId) => ({ ...company.chartCatalog[chartId], id: chartId, instanceId: `${company.id}--${tab.id}--${chartId}`, viewKey: `${company.id}--${chartId}`, params, })); } 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]; } /** * 读数据(带缓存)。缓存的是 Promise 本身,所以同一轮渲染里多张图引用同一份 CSV * 只会发一次请求;失败时立刻把缓存删掉,免得下次重渲染读到同一个坏结果。 */ function loadRows(source) { const key = `${source}?t=${cacheToken}`; if (!rowCache.has(key)) { const pending = loadCSV(key); pending.catch(() => rowCache.delete(key)); rowCache.set(key, pending); } return rowCache.get(key); } /** 图表 option:唯一构造入口,整屏渲染与「只换参数、不重建实例」的局部刷新共用 */ function buildOption(chart, rows, width, height) { const view = chart.views ? activeViews.get(chart.viewKey) || chart.defaultView : undefined; return getChartBuilder(chart.key)(rows, { width, height, view, params: chart.params }); } async function renderChart(chart, version, chartVersion) { const el = document.getElementById(`chart-${chart.instanceId}`); if (!el) return; const source = chartSource(chart); try { const rows = await loadRows(source); 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(buildOption(chart, rows, width, height), true); captureSnapshot(chart); } catch (error) { if (version === renderVersion) showChartError(chart.instanceId, error.message); console.error(`渲染图表失败:${chart.id}`, error); } } /** * 只换参数(现价等)时的局部刷新:复用已有实例 setOption,不 dispose 重建。 * 重建要重新走一遍初始化与布局,连续输入时肉眼可见地卡;而 option 是全量重建的, * setOption(option, true) 直接替换即可,没有残留状态。 */ async function refreshChartParams() { const company = getCompany(state.companyId); const tab = getTab(company, state.tabId); const version = renderVersion; await Promise.all(chartsForTab(company, tab).map(async (chart) => { const el = document.getElementById(`chart-${chart.instanceId}`); const instance = instances.get(chart.instanceId); if (!el || !instance) return; try { const rows = await loadRows(chartSource(chart)); // 期间若发生了整屏重渲染(切 tab / 改尺寸),这个实例已经被 dispose if (version !== renderVersion || instance.isDisposed?.()) return; const { width, height } = el.getBoundingClientRect(); instance.setOption(buildOption(chart, rows, width, height), true); captureSnapshot(chart); } catch (error) { showChartError(chart.instanceId, error.message); console.error(`刷新图表参数失败:${chart.id}`, error); } })); } /** * 应用一次控件输入。返回 false 表示这次输入不合法(壳层据此把输入框标红)。 * 校验放在这里而不是把 min/max 丢给 HTML:type=number 的 min/max 只影响表单校验与 * 上下箭头,手敲越界值照样能进 value,必须自己挡。 */ function applyTabParam(controlId, rawValue, { immediate = false } = {}) { const company = getCompany(state.companyId); const tab = getTab(company, state.tabId); const control = (tab.controls ?? []).find((item) => item.id === controlId); const value = Number.parseFloat(rawValue); if (!control || !Number.isFinite(value) || value < control.min || value > control.max) return false; const params = paramValues(company, tab); if (params[controlId] === value) return true; params[controlId] = value; // 连续输入(例如敲 3、3、8)只重画最后一次:每次重画要重建三张图的图形 clearTimeout(paramTimer); if (immediate) refreshChartParams(); else paramTimer = setTimeout(refreshChartParams, 140); return true; } 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; // 整屏重渲染会带上最新参数把图都画一遍,这里再触发一次参数刷新纯属重复劳动 clearTimeout(paramTimer); disposeAll(); renderCompanies(shell.companyNav, manifest.companies, company.id, (companyId) => { updateHash({ companyId }); }); renderTabs(shell.tabs, company.tabs, tab.id, (tabId) => { updateHash({ companyId: company.id, tabId }); }); renderTabControls(shell.params, tab.controls, paramValues(company, tab), applyTabParam); 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(); rowCache.clear(); 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); });