492 lines
18 KiB
JavaScript
492 lines
18 KiB
JavaScript
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);
|
||
});
|