feat: 增加宁德时代的图表

This commit is contained in:
R524809
2026-09-24 17:14:03 +08:00
parent 3a3d19d5dd
commit f646ee6718
15 changed files with 1459 additions and 240 deletions
+114 -5
View File
@@ -7,6 +7,7 @@ import {
renderCards,
renderCartBar,
renderCompanies,
renderTabControls,
renderTabs,
showChartError,
showMergePreview,
@@ -15,6 +16,12 @@ import {
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();
@@ -25,6 +32,7 @@ let shell;
let state;
let renderVersion = 0;
let cacheToken = Date.now();
let paramTimer;
function validateManifest(value) {
if (!Array.isArray(value?.companies) || value.companies.length === 0) {
@@ -51,6 +59,7 @@ function validateManifest(value) {
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}`);
@@ -61,6 +70,24 @@ function validateManifest(value) {
});
}
/** 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) {
return manifest.companies.find((company) => company.id === companyId) || manifest.companies[0];
}
@@ -69,12 +96,23 @@ 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,
}));
}
@@ -115,22 +153,39 @@ function chartSource(chart) {
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);
const view = chart.views
? activeViews.get(chart.viewKey) || chart.defaultView
: undefined;
try {
const rows = await loadCSV(`${source}?t=${cacheToken}`);
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(getChartBuilder(chart.key)(rows, { width, height, view }), true);
instance.setOption(buildOption(chart, rows, width, height), true);
captureSnapshot(chart);
} catch (error) {
if (version === renderVersion) showChartError(chart.instanceId, error.message);
@@ -138,6 +193,56 @@ async function renderChart(chart, version, chartVersion) {
}
}
/**
* 只换参数(现价等)时的局部刷新:复用已有实例 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();
@@ -298,6 +403,8 @@ function renderCurrent() {
const tab = getTab(company, state.tabId);
const charts = chartsForTab(company, tab);
const version = ++renderVersion;
// 整屏重渲染会带上最新参数把图都画一遍,这里再触发一次参数刷新纯属重复劳动
clearTimeout(paramTimer);
disposeAll();
renderCompanies(shell.companyNav, manifest.companies, company.id, (companyId) => {
@@ -306,6 +413,7 @@ function renderCurrent() {
renderTabs(shell.tabs, company.tabs, tab.id, (tabId) => {
updateHash({ companyId: company.id, tabId });
});
renderTabControls(shell.params, tab.controls, paramValues(company, tab), applyTabParam);
renderCards(shell.cards, charts, activeViews, {
onExport: exportPNG,
isCarted: (instanceId) => cart.has(instanceId),
@@ -335,6 +443,7 @@ async function start() {
shell = createAppShell(document.getElementById('app'), {
onReload: () => {
cacheToken = Date.now();
rowCache.clear();
renderCurrent();
},
});