Files
smart-fin-chart/js/main.js
T
2026-08-28 09:30:13 +08:00

193 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});