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(); 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'); } const chartIds = new Set(); value.companies.forEach((company) => { if (!company.id || !Array.isArray(company.tabs)) throw new Error('manifest 中存在无效公司配置'); const tabIds = new Set(); company.tabs.forEach((tab) => { if (!tab.id || tabIds.has(tab.id)) throw new Error(`公司 ${company.id} 的 tab id 重复或为空`); tabIds.add(tab.id); (tab.charts || []).forEach((chart) => { if (!chart.id || chartIds.has(chart.id)) throw new Error(`图表 id 重复或为空:${chart.id || '(空)'}`); if (!getChartBuilder(chart.key)) throw new Error(`图表 ${chart.id} 引用了未注册 key:${chart.key}`); if (Boolean(chart.csv) === Boolean(chart.views)) throw new Error(`图表 ${chart.id} 必须声明 csv 或 views 之一`); if (chart.views && (!chart.defaultView || !chart.views[chart.defaultView])) { throw new Error(`图表 ${chart.id} 的 defaultView 无效`); } chartIds.add(chart.id); }); }); }); } 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 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 = tab.charts.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.id, 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.id) || chart.defaultView; return chart.views[view]; } async function renderChart(chart, version) { const el = document.getElementById(`chart-${chart.id}`); if (!el) return; const source = chartSource(chart); try { const rows = await loadCSV(`${source}?t=${cacheToken}`); if (version !== renderVersion || !document.body.contains(el)) return; const instance = echarts.init(el, null, { renderer: 'canvas' }); instances.set(chart.id, instance); const { width, height } = el.getBoundingClientRect(); instance.setOption(getChartBuilder(chart.key)(rows, { width, height }), true); } catch (error) { if (version === renderVersion) showChartError(chart.id, error.message); console.error(`渲染图表失败:${chart.id}`, error); } } 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(chartId) { state = { ...state, chartId, view: activeViews.get(chartId) }; history.replaceState(null, '', serializeHash(state)); document.getElementById(`card-${chartId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function renderCurrent() { state = normalizeState(state); const company = getCompany(state.companyId); const tab = getTab(company, state.tabId); 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, tab.charts || [], activeViews, { onExport: exportPNG, onSelectView: (chartId, view) => { activeViews.set(chartId, view); updateHash({ companyId: company.id, tabId: tab.id, chartId, view }); }, }); renderAnchors(shell.chartNav, tab.charts || [], selectAnchor); Promise.all((tab.charts || []).map((chart) => renderChart(chart, version))); } 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); });