Files
smart-fin-chart/js/main.js
T
2026-09-09 15:31:05 +08:00

364 lines
12 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 { downloadCanvas, mergeImages } from './merge.js';
import {
createAppShell,
renderAnchors,
renderCards,
renderCartBar,
renderCompanies,
renderTabs,
showChartError,
showMergePreview,
} from './ui.js';
const instances = new Map();
const activeViews = new Map();
const chartRenderVersions = new Map();
// 合并队列:instanceId → { instanceId, title, filename, dataUrl }
// dataUrl 是加购(及后续每次重渲染)时的快照,切换 tab 后实例被 dispose 也能照常导出
const cart = new Map();
let cartMode = 'pairs';
let closePreview;
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);
captureSnapshot(chart);
} 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();
}
/** 图表在合并队列中时,每次重渲染(切换视图、重载数据、resize)都刷新快照,保证所见即所得 */
function captureSnapshot(chart) {
if (!cart.has(chart.instanceId)) return;
const instance = instances.get(chart.instanceId);
if (!instance) return;
cart.get(chart.instanceId).dataUrl = instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' });
}
function toggleCart(chart) {
if (cart.delete(chart.instanceId)) {
updateCartButton(chart.instanceId);
} else {
const instance = instances.get(chart.instanceId);
if (!instance) return;
cart.set(chart.instanceId, {
instanceId: chart.instanceId,
title: chart.title,
filename: chart.filename || chart.title,
dataUrl: instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' }),
});
updateCartButton(chart.instanceId);
}
renderCart();
}
function updateCartButton(instanceId) {
const btn = document.querySelector(`[data-cart-id="${instanceId}"]`);
if (!btn) return;
const inCart = cart.has(instanceId);
btn.dataset.inCart = String(inCart);
btn.textContent = inCart ? '已加入 ✓' : '➕ 加入合并';
}
/** Map 保持插入序,删除后按新顺序重建即完成上移/下移 */
function moveCartItem(instanceId, delta) {
const entries = [...cart.entries()];
const from = entries.findIndex(([id]) => id === instanceId);
const to = from + delta;
if (from < 0 || to < 0 || to >= entries.length) return;
[entries[from], entries[to]] = [entries[to], entries[from]];
cart.clear();
entries.forEach(([id, item]) => cart.set(id, item));
renderCart();
}
function clipName(name, max = 24) {
return name.length > max ? `${name.slice(0, max)}…` : name;
}
/** 按当前模式把队列合成为一组 { canvas, filename, partial? }:pairs 两两合并,single 全部拼一张 */
async function buildCompositions() {
const items = [...cart.values()];
if (!items.length) return [];
if (cartMode === 'single') {
return [{
canvas: await mergeImages(items.map((item) => item.dataUrl)),
filename: `${clipName(items[0].filename)}等${items.length}张合并`,
}];
}
const pairs = [];
for (let i = 0; i < items.length; i += 2) {
const pair = items.slice(i, i + 2);
pairs.push({
canvas: await mergeImages(pair.map((item) => item.dataUrl)),
filename: pair.map((item) => clipName(item.filename)).join('+'),
partial: pair.length === 1,
});
}
return pairs;
}
async function openMergePreview() {
const entries = await buildCompositions();
if (!entries.length) return;
const notice = entries.some((entry) => entry.partial) ? '队列为奇数张,最后一张单独导出' : '';
const download = (entry) => downloadCanvas(entry.canvas, entry.filename);
closePreview = showMergePreview(entries, {
notice,
onDownload: download,
onDownloadAll: () => entries.forEach((entry, index) => {
setTimeout(() => download(entry), index * 350);
}),
onClose: () => closePreview?.(),
});
}
function renderCart() {
renderCartBar(shell.cartBar, {
items: [...cart.values()],
mode: cartMode,
onModeChange: (mode) => {
cartMode = mode;
renderCart();
},
onMove: moveCartItem,
onRemove: (instanceId) => {
cart.delete(instanceId);
updateCartButton(instanceId);
renderCart();
},
onClear: () => {
const ids = [...cart.keys()];
cart.clear();
ids.forEach(updateCartButton);
renderCart();
},
onPreview: openMergePreview,
});
}
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,
isCarted: (instanceId) => cart.has(instanceId),
onToggleCart: toggleCart,
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);
});