193 lines
6.4 KiB
JavaScript
193 lines
6.4 KiB
JavaScript
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);
|
||
});
|