Files
smart-fin-chart/js/main.js
T
2026-10-22 15:43:17 +08:00

492 lines
18 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 { renderStockReturnCalculator, valuationMenu } from './tools/stock-return-calculator.js';
import { renderDCFCalculator } from './tools/dcf-calculator.js';
import {
createAppShell,
renderAnchors,
renderCards,
renderCartBar,
renderCompanies,
renderTabControls,
renderTabs,
showChartError,
showMergePreview,
} from './ui.js';
const instances = new Map();
const activeViews = new Map();
const chartRenderVersions = new Map();
// tab 级参数(现价等):`${companyId}/${tabId}` → { price: 342.5 }
// 存进 Map 而不是 DOM,是为了切 tab / 改窗口尺寸重渲染后还能带着值回来
const tabParams = new Map();
// CSV 缓存:source → Promise<rows>。缓存 Promise,同一 tab 的多张图共用一次请求,
// 手输现价连续重渲染也不会重复拉数据;「重新加载数据」换 cacheToken 即整体失效。
const rowCache = 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();
let paramTimer;
let disposeTool;
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);
validateControls(company.id, tab);
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);
});
});
});
}
/** tab.controls 是图表参数的声明处(现价等),这里挡住会导致输入框静默失效的配置 */
function validateControls(companyId, tab) {
const seen = new Set();
(tab.controls ?? []).forEach((control) => {
const where = `公司 ${companyId} 的 ${tab.id} 控件 ${control.id || '(空)'}`;
if (!control.id || seen.has(control.id)) throw new Error(`${where} 的 id 重复或为空`);
seen.add(control.id);
if (control.type !== 'number') throw new Error(`${where} 的 type 不受支持:${control.type}`);
['value', 'min', 'max'].forEach((field) => {
if (!Number.isFinite(control[field])) throw new Error(`${where} 的 ${field} 必须是数字`);
});
if (control.min > control.max) throw new Error(`${where} 的 min > max`);
if (control.value < control.min || control.value > control.max) {
throw new Error(`${where} 的默认值 ${control.value} 不在 ${control.min}–${control.max} 内`);
}
});
}
function getCompany(companyId) {
if (companyId === valuationMenu.id) return valuationMenu;
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];
}
/** tab 参数:首次访问时用 manifest 的默认值播种 */
function paramValues(company, tab) {
const key = `${company.id}/${tab.id}`;
if (!tabParams.has(key)) {
tabParams.set(key, Object.fromEntries((tab.controls ?? []).map((control) => [control.id, control.value])));
}
return tabParams.get(key);
}
function chartsForTab(company, tab) {
const params = paramValues(company, tab);
return tab.chartRefs.map((chartId) => ({
...company.chartCatalog[chartId],
id: chartId,
instanceId: `${company.id}--${tab.id}--${chartId}`,
viewKey: `${company.id}--${chartId}`,
params,
}));
}
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];
}
/**
* 读数据(带缓存)。缓存的是 Promise 本身,所以同一轮渲染里多张图引用同一份 CSV
* 只会发一次请求;失败时立刻把缓存删掉,免得下次重渲染读到同一个坏结果。
*/
function loadRows(source) {
const key = `${source}?t=${cacheToken}`;
if (!rowCache.has(key)) {
const pending = loadCSV(key);
pending.catch(() => rowCache.delete(key));
rowCache.set(key, pending);
}
return rowCache.get(key);
}
/** 图表 option:唯一构造入口,整屏渲染与「只换参数、不重建实例」的局部刷新共用 */
function buildOption(chart, rows, width, height) {
const view = chart.views ? activeViews.get(chart.viewKey) || chart.defaultView : undefined;
return getChartBuilder(chart.key)(rows, { width, height, view, params: chart.params });
}
async function renderChart(chart, version, chartVersion) {
const el = document.getElementById(`chart-${chart.instanceId}`);
if (!el) return;
const source = chartSource(chart);
try {
const rows = await loadRows(source);
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(buildOption(chart, rows, width, height), true);
captureSnapshot(chart);
} catch (error) {
if (version === renderVersion) showChartError(chart.instanceId, error.message);
console.error(`渲染图表失败:${chart.id}`, error);
}
}
/**
* 只换参数(现价等)时的局部刷新:复用已有实例 setOption,不 dispose 重建。
* 重建要重新走一遍初始化与布局,连续输入时肉眼可见地卡;而 option 是全量重建的,
* setOption(option, true) 直接替换即可,没有残留状态。
*/
async function refreshChartParams() {
const company = getCompany(state.companyId);
const tab = getTab(company, state.tabId);
const version = renderVersion;
await Promise.all(chartsForTab(company, tab).map(async (chart) => {
const el = document.getElementById(`chart-${chart.instanceId}`);
const instance = instances.get(chart.instanceId);
if (!el || !instance) return;
try {
const rows = await loadRows(chartSource(chart));
// 期间若发生了整屏重渲染(切 tab / 改尺寸),这个实例已经被 dispose
if (version !== renderVersion || instance.isDisposed?.()) return;
const { width, height } = el.getBoundingClientRect();
instance.setOption(buildOption(chart, rows, width, height), true);
captureSnapshot(chart);
} catch (error) {
showChartError(chart.instanceId, error.message);
console.error(`刷新图表参数失败:${chart.id}`, error);
}
}));
}
/**
* 应用一次控件输入。返回 false 表示这次输入不合法(壳层据此把输入框标红)。
* 校验放在这里而不是把 min/max 丢给 HTML:type=number 的 min/max 只影响表单校验与
* 上下箭头,手敲越界值照样能进 value,必须自己挡。
*/
function applyTabParam(controlId, rawValue, { immediate = false } = {}) {
const company = getCompany(state.companyId);
const tab = getTab(company, state.tabId);
const control = (tab.controls ?? []).find((item) => item.id === controlId);
const value = Number.parseFloat(rawValue);
if (!control || !Number.isFinite(value) || value < control.min || value > control.max) return false;
const params = paramValues(company, tab);
if (params[controlId] === value) return true;
params[controlId] = value;
// 连续输入(例如敲 3、3、8)只重画最后一次:每次重画要重建三张图的图形
clearTimeout(paramTimer);
if (immediate) refreshChartParams();
else paramTimer = setTimeout(refreshChartParams, 140);
return true;
}
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);
shell.layout.classList.toggle('tools-layout', company.kind === 'tools');
const version = ++renderVersion;
// 整屏重渲染会带上最新参数把图都画一遍,这里再触发一次参数刷新纯属重复劳动
clearTimeout(paramTimer);
disposeTool?.();
disposeTool = undefined;
disposeAll();
renderCompanies(shell.companyNav, [...manifest.companies, valuationMenu], company.id, (companyId) => {
updateHash({ companyId });
});
renderTabs(shell.tabs, company.tabs, tab.id, (tabId) => {
updateHash({ companyId: company.id, tabId });
});
renderTabControls(shell.params, tab.controls, paramValues(company, tab), applyTabParam);
if (company.kind === 'tools') {
if (tab.id === 'dcf') {
disposeTool = renderDCFCalculator(shell.cards, manifest.companies.filter(item => ['ppmt', 'moutai', 'catl'].includes(item.id)));
} else {
renderStockReturnCalculator(shell.cards);
}
shell.chartNav.hidden = true;
return;
}
shell.chartNav.hidden = false;
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();
rowCache.clear();
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;
// Calculator layout is responsive CSS; rebuilding it would interrupt focused inputs.
if (getCompany(state.companyId).kind === 'tools') return;
clearTimeout(resizeTimer);
resizeTimer = setTimeout(renderCurrent, 200);
});