import { loadCSV } from './csv.js'; import { getChartBuilder } from './charts/index.js'; import { createAppShell, renderAnchors, renderCards, renderCompanies, renderTabs, showChartError, } from './ui.js'; const instances = new Map(); const activeViews = new Map(); const chartRenderVersions = new Map(); 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); } 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(); } 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, 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); });