feat: 增加宁德时代的图表
This commit is contained in:
+114
-5
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user