Files
smart-fin-chart/js/main.js
T

243 lines
8.3 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();
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);
});