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
+4
View File
@@ -54,6 +54,7 @@ import {
} from './catl.js';
import {
sweetCatlPricePeChart, sweetCatlNetCashChart, sweetCatlAnyPriceChart,
sweetTencentPriceEvChart, sweetTencentNonOpChart, sweetTencentAnyPriceChart,
} from './sweet_spot.js';
/** 图表构建函数注册表。manifest 只引用这里的 key,不直接引用 JS 函数。 */
@@ -140,6 +141,9 @@ export const chartRegistry = {
'sweet-catl-price-pe': sweetCatlPricePeChart,
'sweet-catl-net-cash': sweetCatlNetCashChart,
'sweet-catl-any-price': sweetCatlAnyPriceChart,
'sweet-tencent-price-ev': sweetTencentPriceEvChart,
'sweet-tencent-non-op': sweetTencentNonOpChart,
'sweet-tencent-any-price': sweetTencentAnyPriceChart,
};
export function getChartBuilder(key) {
+555 -215
View File
@@ -4,60 +4,258 @@ import {
} from '../theme.js';
/**
* 甜蜜击球区 · 宁德时代(300750)—— 股价 ↔ 2026E 估值 的换算图表
* 甜蜜击球区 · 股价 ↔ 估值 的换算图表(宁德时代 300750 / 腾讯控股 00700.HK)
*
* 上游底稿:资料库节点「宁德时代_股价PE对照表」
* https://www.workbuddy.cn/space/d/EsAWMaLKt4ECKPDvESKKvT(2026-09-15 版)
* 数据由 scripts/build_sweet_spot_catl.py 投影成 data/sweetspot/*.csv。
* 同一套框架、两套口径。三张图的结构完全一致,差异全部下沉到 PROFILES 的两个配置块里:
* 图 1 股价 → 倍数对照(三档情景 + 倍数轴上的区域横带)
* 图 2 非经营资产让倍数便宜了多少(含/扣各层级 + 中间那层沙色带)
* 图 3 任意股价试算(三年后退出,年化收益 + 股价轴上的纵向区域带)
*
* 【口径】所有 PE 都是**静态预期 PE**:市值 ÷ 2026E 归母净利(悲观 900 / 中性 964 /
* 乐观 1,000 亿元,65 家机构一致预期)。扣现金 PE =(市值 − 净现金 2,263.8 亿元)÷
* 2026E 净利,即"只给主营电池业务定价"。股息率按 2023–2025 年实际分红率 50% 测算。
* 【为什么纵轴口径两家不同】这不是随意的:
* 宁德 纵轴 PE = 市值 ÷ 2026E 归母净利;它扣的「类现金」是 2,263.8 亿趴在账上的现金,
* 是准现金,扣掉后不改变分子的性质,所以 PE 口径成立。
* 腾讯 纵轴 EV/EBIT;它的「类现金」是 5,259.9 亿投资组合(股权、会波动),
* 只扣净现金 581.9 亿几乎不改变倍数(少 0.23x),必须连投资组合一起扣才是经营资产
* 的真实价格;同时分母换成经营盈利 EBIT,分子分母成对(分子不含投资收益)。
* 两家的甜蜜区锚也不同:宁德的五档来自资料库原表(13/15.5/25/30x);
* 腾讯用巴菲特原始锚 8x 白送 · 10x 甜点 · 12x 上沿。
*
* 【甜蜜区框架】8x 白送 · 10x 甜点 · 12x 上沿 是巴菲特锚;资料库原表用的是五档
* 更宽的区域(按中性情景 PE 划分),因为它的分子是"含净现金的市值"。两套档位不要
* 混读:本文所有区域带一律用原表的五档,定义集中在 PE_ZONES,改档位只改这一处。
* 【上游底稿】宁德:资料库「宁德时代_股价PE对照表」EsAWMaLKt4ECKPDvESKKvT(2026-09-15);
* 腾讯:资料库「腾讯控股(00700.HK)甜蜜击球区检验」Ey5NeiPls71FC12zn0ieSX(2026-09-17)。
* 数据由 scripts/build_sweet_spot_catl.py / build_sweet_spot_tencent.py 投影成 CSV。
*
* 【图 1】股价 → PE 对照(PE 轴上的横向区域带)
* 【图 2】含现金 PE vs 扣现金 PE(沙色带 = 净现金的厚度)
* 【图 3】任意股价试算(股价轴上的纵向区域带 + 三年预期年化收益)
* 【硬编码说明】区域边界的**股价**不是写死的,而是从 CSV 的基准倍数反解(multToPrice),
* 所以改股本/汇率/利润预期后纵向区域带会自动跟着走。配置块里只有三类手工常量:
* 现价默认值、非经营资产(仅用于文案)、以及区域档位本身。
*
* 精度例外:PE 保留两位小数。项目默认一位,但"现价落在买入区还是合理区"取决于
* 15.18x 与 15.5x 这条边界,一位小数会把 15.18 读成 15.2、与 15.5 分不清。
*
* 硬编码说明:区域边界的**股价**不是写死的,而是从 CSV 的 pe_base 反解(peToPrice),
* 所以改股本或利润预期后纵向区域带会自动跟着走。本文只有两个手工常量:现价 NOW 与
* 净现金 NET_CASH(仅用于文案),都与 build_sweet_spot_catl.py 同源。
* 【现价可手输】三张图的「现价」标线不是常量,而是 tab 级参数 `params.price`
* (在 manifest.json 各 tab 的 controls 里声明,见 js/main.js)。profile.priceDefault
* 是无参数调用(SSR 回归测试、脚本)的兜底。
*/
/** 现价(2026-09-15 收盘)。与生成脚本同源,重跑脚本时要同步。 */
const NOW = 316.21;
/* ============================================================
* 配置:宁德时代(300750)
* ============================================================ */
/** 净现金(亿元)。仅用于图 2 的文案;沙色带的几何量取自 CSV 的 pe_base − pe_ex_cash。 */
const NET_CASH = 2263.8;
const CATL = {
id: 'catl',
company: '宁德时代',
currency: '元',
/** 默认现价 = 2026-09-15 收盘价。三处同源,改它要一起改:
* 本文件 · manifest.json 的 controls[].value · scripts/build_sweet_spot_catl.py */
priceDefault: 316.21,
/** 非经营资产(亿元)。仅用于文案;沙色带的几何量取自 CSV 的倍率差。 */
nonOp: 2263.8,
/** 数据区(= 输入区间)与坐标轴窗口(比数据区各宽 20 元,标线不贴边) */
dataMin: 220,
dataMax: 700,
/** CSV 的价格网格步长(= scripts/build_sweet_spot_catl.py 里 price_grid 的 step)。
* 现价与关键价位的标签避让阈值就用它,见 refLabels()。 */
dataStep: 20,
axis: { min: 200, max: 720, name: '股价(元)', metricMin: 8, metricMax: 38, metricInterval: 5 },
metric: { name: 'PE(倍)', unit: 'x', decimals: 2 },
/** 三档情景的基准是「2026E 归母净利」,65 家机构一致预期 */
sourceNote: '数据来源:宁德时代 2026 中报(本地财报库)· 65 家机构 2026E 一致预期',
/**
* 五档估值区域(按中性情景 964 亿 PE 的上沿划分)。
* 原表把 15.5–25x 细分为「合理偏低」与「合理」两档、但共用同一个配色,
* 这里并为一条色带,避免在 PE 轴上叠出两块无差别的底色。
*/
zones: [
{ name: '击球区', upTo: 13, color: PALETTE.green, note: '<13x' },
{ name: '买入区', upTo: 15.5, color: PALETTE.blue, note: '13–15.5x' },
{ name: '合理', upTo: 25, color: PALETTE.orange, note: '15.5–25x' },
{ name: '偏贵', upTo: 30, color: PALETTE.red, note: '25–30x' },
{ name: '卖出区', upTo: Infinity, color: PALETTE.purple, note: '>30x' },
],
multiple: {
title: '股价 → 2026E PE 对照',
subtitle: (ctx) => `单位:倍 · 三档利润情景 · 区域底带按中性 964 亿划分 · 现价 ${fmtPrice(ctx.now)} 元`,
labels: [265, 480, 605],
nowLabel: '中性',
nowDecimals: 2,
nowUnit: 'x',
notes: () => [
'PE = 市值 ÷ 2026E 归母净利,与股价严格成正比;三档情景只差 ±6% 利润,落到 PE 上不足 1.7x',
'原表把 15.5–25x 细分为「合理偏低」与「合理」,此处并为一条色带;扣现金 PE 见图 2',
],
/** 从上到下:第一条标 2 个关键价位、其余只画线(三条线几乎平行,全标会糊) */
scenarios: [
{ key: 'pe_bear', name: '悲观 900 亿', color: FINANCIAL_COLORS.operatingCashFlow, width: 2.4 },
{ key: 'pe_base', name: '中性 964 亿', color: FINANCIAL_COLORS.netProfit, width: 3.5, hero: true, label: 'right' },
{ key: 'pe_bull', name: '乐观 1,000 亿', color: FINANCIAL_COLORS.investingCashFlow, width: 2.4 },
],
legend: ['悲观 900 亿', '中性 964 亿', '乐观 1,000 亿'],
},
tiers: {
title: '净现金让 PE 便宜了多少',
subtitle: (ctx) => `单位:倍 · 中性 964 亿口径 · 沙色带即净现金 ${ctx.nonOpText} 亿元对应的 PE`,
/** 从上到下。层间的差就是非经营资产的厚度,栈底 = 最后一条(扣得最狠的口径)。 */
lines: [
{ name: '含现金 PE', key: 'pe_base', color: FINANCIAL_COLORS.investingCashFlow, width: 3 },
{ name: '扣现金 PE', key: 'pe_ex_cash', color: PALETTE.brown, width: 3.5 },
],
/** 沙色带(自下而上),名字不进图例(线宽 0,图例色块会是空的) */
bands: [{ name: '净现金差额', color: PALETTE.sand }],
pctKey: 'net_cash_pct',
nowLabel: '扣现金',
nowDecimals: 2,
nowUnit: 'x',
notes: (ctx) => [
'含现金 PE = 市值 ÷ 2026E 净利;扣现金 PE =(市值 − 净现金)÷ 2026E 净利,即只给主营电池业务定价',
`两条线严格平行:差额恒为 ${ctx.bands[0].gap.toFixed(2)}x,但占市值比例从 ${ctx.dataMin} 元的 `
+ `${ctx.minPct}% 收窄到 ${ctx.dataMax} 元的 ${ctx.maxPct}% —— 股价越低,这层折价越厚`,
],
},
anyPrice: {
title: '任意股价试算:三年预期年化收益',
subtitle: () => '单位:% · 目标价 605 元(2028E 1,400 亿 × 20x)· 纵带为估值区域',
lines: [
{
name: '含分红年化', key: 'cagr_total', color: FINANCIAL_COLORS.netProfit, width: 3.5,
hero: true, labels: [265, 450, 605], position: 'top', distance: 10,
},
{
name: '仅价差年化', key: 'cagr_price', color: FINANCIAL_COLORS.investingCashFlow, width: 2.6,
labels: [265, 450], position: 'bottom', distance: 10,
},
],
legend: ['含分红年化', '仅价差年化'],
nowLabel: '含分红年化',
nowDecimals: 1,
nowUnit: '%',
/**
* 纵轴下限 -20(不是 -10):顶端 700 元处「仅价差年化」已跌到 -4.7%,-10 只留 5.3pp 余量,
* 而纵带的名字(「卖出区」等)就画在绘图区底边上方 12–25px 那条带上 —— 手输 680 元时
* 「-3.8%」标签与「卖出区」实打实重叠 22×7px(2026-09-22 全价位扫描量出)。
* 留到 -20 后底部那条带彻底空出来。腾讯那份本来就是 -20 起,两家口径统一。
*/
yAxis: { min: -20, max: 50, interval: 10 },
notes: (ctx) => [
'年化收益 =(605 ÷ 股价)^(1/3) − 1;含分红口径再乘 (1 + 股息率),股息按当前价每年派现、不复投',
`纵带边界(${ctx.currency}):${ctx.boundary}`,
],
},
};
/** 2028E 目标价(1,400 亿 × 20x ÷ 46.27 亿股),图 3 的年化收益基准。 */
const TARGET_2028 = 605;
/* ============================================================
* 配置:腾讯控股(00700.HK)
* ============================================================ */
/**
* 五档估值区域(按中性情景 964 亿 PE 的上沿划分)。
* 原表把 15.5–25x 细分为「合理偏低」与「合理」两档、但共用同一个配色,
* 这里并为一条色带,避免在 PE 轴上叠出两块无差别的底色。
*/
const PE_ZONES = [
{ name: '击球区', upTo: 13, color: PALETTE.green, note: '<13x' },
{ name: '买入区', upTo: 15.5, color: PALETTE.blue, note: '13–15.5x' },
{ name: '合理', upTo: 25, color: PALETTE.orange, note: '15.5–25x' },
{ name: '偏贵', upTo: 30, color: PALETTE.red, note: '25–30x' },
{ name: '卖出区', upTo: Infinity, color: PALETTE.purple, note: '>30x' },
];
const TENCENT = {
id: 'tencent',
company: '腾讯控股',
currency: '港元',
/** 默认现价 = 2026-09-17 收盘价(与底稿同源,三处同:本文件 · manifest · 生成脚本) */
priceDefault: 426.8,
/**
* 非经营资产 = 净现金 581.9 + 上市投资公允价值 4,872.0 + 非上市投资账面 387.9
* = 5,841.8 亿元(占市值 17.5%)。这是腾讯与宁德口径上最大的差异:
* 宁德的类现金是纯现金,腾讯的是股权组合(其中投资组合 5,259.9 亿),会波动。
*/
nonOp: 5841.8,
dataMin: 240,
dataMax: 640,
/** CSV 的价格网格步长,见 refLabels()。两家同为 20 —— 不是巧合,是两份生成脚本
* 都用了 price_grid(top, 20, key_prices)。改步长要连这里的避让阈值一起想。 */
dataStep: 20,
axis: { min: 220, max: 660, name: '股价(港元)', metricMin: 3, metricMax: 19, metricInterval: 2 },
metric: { name: 'EV/EBIT(倍)', unit: 'x', decimals: 2 },
sourceNote: '数据来源:腾讯控股 2026 中期报告及 2026Q2 业绩公告 · TTM = 2025 全年 − 2025H1 + 2026H1',
/**
* 巴菲特原始锚:8x 白送 · 10x 甜点 · 12x 上沿。
* 超过 12x 就是出区,所以这里的两档「贵」不需要像宁德那样细化 —— 框架里没有卖出档,
* 只有一个「够不够便宜」的判断。
*/
zones: [
{ name: '白送', upTo: 8, color: PALETTE.green, note: '<8x' },
{ name: '甜蜜区', upTo: 10, color: PALETTE.blue, note: '8–10x' },
{ name: '上沿', upTo: 12, color: PALETTE.orange, note: '10–12x' },
{ name: '超出上沿', upTo: 15, color: PALETTE.red, note: '12–15x' },
{ name: '偏贵', upTo: Infinity, color: PALETTE.purple, note: '>15x' },
],
multiple: {
title: '股价 → EV/EBIT 对照',
subtitle: (ctx) => `单位:倍 · 三档 EBIT 情景 · 区域带按 8x/10x/12x 划分 · 现价 ${fmtPrice(ctx.now)} 港元`,
/** 甜蜜区标尺:8x 白送 / 10x 甜点 / 12x 上沿 */
labels: [338.5, 404.5, 470.5],
nowLabel: '基准',
nowDecimals: 2,
nowUnit: 'x',
notes: () => [
'EV/EBIT =(市值 − 非经营资产)÷ TTM EBIT;三档 EBIT 只差 ±10%,落到倍数上放大到近 2x',
'非经营资产 5,841.8 亿 = 净现金 581.9 + 上市投资 4,872.0 + 非上市投资 387.9(扣法见图 2)',
],
scenarios: [
{ key: 'mult_bear', name: '悲观 2,400 亿', color: FINANCIAL_COLORS.operatingCashFlow, width: 2.4 },
{ key: 'mult_base', name: '基准 2,585 亿', color: FINANCIAL_COLORS.netProfit, width: 3.5, hero: true, label: 'right' },
{ key: 'mult_bull', name: '乐观 2,900 亿', color: FINANCIAL_COLORS.investingCashFlow, width: 2.4 },
],
legend: ['悲观 2,400 亿', '基准 2,585 亿', '乐观 2,900 亿'],
},
tiers: {
title: '扣掉非经营资产后,倍数低了多少',
subtitle: (ctx) => `单位:倍 · 基准 EBIT 2,585 亿 · 沙色带即非经营资产 ${ctx.nonOpText} 亿元对应的倍数`,
/** 三层口径:不扣(市值)→ 只扣净现金 → 连投资组合一起扣。中间那层是「假阴性」的来源。 */
lines: [
{ name: '不扣任何资产', key: 'mult_gross', color: FINANCIAL_COLORS.netCashIncrease, width: 2.6 },
{ name: '只扣净现金', key: 'mult_net_cash', color: PALETTE.grey, width: 2.6 },
{ name: '扣净现金 + 投资组合', key: 'mult_base', color: PALETTE.brown, width: 3.5 },
],
bands: [
{ name: '投资组合差额', color: PALETTE.sand },
{ name: '净现金差额', color: PALETTE.sand },
],
pctKey: 'non_op_pct',
nowLabel: '全扣',
nowDecimals: 2,
nowUnit: 'x',
notes: (ctx) => [
`只扣净现金只挪动 ${ctx.bands[1].gap.toFixed(2)}x;真正厚的是投资组合(${ctx.bands[0].gap.toFixed(2)}x)—— `
+ '这是腾讯与宁德最大的口径差异',
'漏扣投资组合会得出「超上沿」的假阴性:它是股权不是现金、会波动,打 7 折后基准倍数回到 11.29x',
],
},
anyPrice: {
title: '任意股价试算:三年退出年化收益',
subtitle: () => '单位:% · 三年后退出在甜蜜区上沿 12x / 甜点 10x · 纵带为估值区域',
/**
* 目标价三年不变(不给 EBIT 增长),三条线的差别只在「退出倍数」与「分母是否被折旧侵蚀」:
* 12x 上沿 470.5 港元(= 报告「三·甜蜜区股价标尺」的 12x 一档)
* 10x 甜点 404.5 港元
* 折旧后 12x 418.7 港元(5 年直线折旧,稳态 EBIT 2,247 亿 —— 报告「四·分母的定时炸弹」)
*/
lines: [
{
name: '退出 12x 上沿', key: 'cagr_12x', color: FINANCIAL_COLORS.netProfit, width: 3.5,
hero: true, labels: [371.5, 404.5, 470.5], position: 'top', distance: 10,
},
{
name: '退出 10x 甜点', key: 'cagr_10x', color: FINANCIAL_COLORS.investingCashFlow, width: 2.6,
labels: [404.5], position: 'bottom', distance: 12,
},
{
name: '12x 上沿 · 折旧后', key: 'cagr_12x_dep', color: FINANCIAL_COLORS.netCashIncrease, width: 2.6,
labels: [],
},
],
legend: ['退出 12x 上沿', '退出 10x 甜点', '12x 上沿 · 折旧后'],
nowLabel: '退出 12x 上沿',
nowDecimals: 1,
nowUnit: '%',
yAxis: { min: -20, max: 30, interval: 10 },
notes: (ctx) => [
'目标价 =(倍数 × EBIT + 非经营资产)÷ 90.95 亿股 ÷ 0.86154;EBIT 不给增长(三年后与今日同口径)',
`纵带边界(${ctx.currency}):${ctx.boundary}`,
],
},
};
const PE_MIN = 8;
const PE_MAX = 38;
const PRICE_MIN = 200;
const PRICE_MAX = 720;
const PROFILES = { catl: CATL, tencent: TENCENT };
const SOURCE_NOTE = '数据来源:宁德时代 2026 中报(本地财报库)· 65 家机构 2026E 一致预期';
/* ============================================================
* 通用工具(两家公司共用)
* ============================================================ */
/** 十六进制转 rgba —— 区域带要低透明度叠在网格上,不能直接铺饱和色 */
function tint(hex, alpha) {
@@ -65,13 +263,58 @@ function tint(hex, alpha) {
return `rgba(${(value >> 16) & 255},${(value >> 8) & 255},${value & 255},${alpha})`;
}
/** 取某价位的某个指标;缺失返回 NaN,让标签渲染成 NaN 而不是抛错 */
/** 取某价位的某个指标:命中数据行就用原值,否则在相邻两行之间线性插值 */
function metricAt(rows, price, key) {
const row = rows.find((item) => item.price === price);
return row ? row[key] : Number.NaN;
const hit = rows.find((item) => item.price === price);
if (hit) return hit[key];
for (let i = 1; i < rows.length; i += 1) {
const low = rows[i - 1];
const high = rows[i];
if (price > low.price && price < high.price) {
const ratio = (price - low.price) / (high.price - low.price);
return low[key] + ratio * (high[key] - low[key]);
}
}
return Number.NaN;
}
/** 数值轴(PE / 收益率共用),与 catl.js 的 valueAxis 同一套样式 */
/**
* 解析 tab 参数里的现价。手输价格通常不落在 CSV 的价位上(价位是 240/260/…/426.8/440/…),
* 所以取值一律走 metricAt 的插值:倍数与股价严格成正比(截距是非经营资产)、
* 年化收益是幂函数,相邻两行最多差 20 元,插值误差远小于显示精度。
*/
function resolvePrice(profile, params) {
const value = Number(params?.price);
return Number.isFinite(value) && value > 0 ? value : profile.priceDefault;
}
/** 价格文案:316.21 → '316.21'、320 → '320'(手输整数不带小数点) */
function fmtPrice(value) {
return String(Number(value.toFixed(2)));
}
/**
* 关键价位标签 + 现价标签。
*
* 数据点标签只能落在数据行上(`label.position` 不接受函数,只能逐点覆盖 data),
* 手输的现价不一定正好是数据行,所以只有命中数据行时才把它也标出来 ——
* 命不中也不亏,现价的数值由 nowGraphics 的标注直接给出。
*
* 【避让】现价与某个关键价位落在同一个数据格内时,**关键价位让位**:两者数值几乎
* 相同,两个标签的包围盒会实打实叠在一起(宁德输 260 元时,现价 12.48x 与关键价
* 265 元的 12.72x 重叠 34×11px;图 3 上「37.8% × 36.9%」「32.5% × 31.7%」各一处,
* 2026-09-22 用 geometry_check 量出)。现价永远保留 —— 它是这次交互的主角。
*
* 阈值取「一个数据格」而不是现算像素:标签宽约 43px、每元约 1.8px,像素阈值会随
* 轴范围漂;而两家的 CSV 网格步长都是 20,20 元本身就约等于一个标签的宽度,
* 越过一格就必然分得开。实测两家真实阈值 20.3(宁德)/ 17.2(腾讯),取 20 两边都够。
*/
function refLabels(profile, rows, now, refs) {
if (!rows.some((row) => row.price === now)) return refs;
return [...refs.filter((ref) => Math.abs(ref - now) > profile.dataStep), now];
}
/** 数值轴(倍数 / 年化收益共用),与 catl.js 的 valueAxis 同一套样式 */
const valueAxis = (name, extra = {}) => ({
...axisCommon,
type: 'value',
@@ -87,14 +330,14 @@ const valueAxis = (name, extra = {}) => ({
* 不画竖向网格(规范只保留浅色横线)—— 图 3 的纵向区域带本身就是参考线,
* 图 1、图 2 靠「现价」竖线定位。
*/
const priceAxis = (extra = {}) => ({
const priceAxis = (profile, extra = {}) => ({
...axisCommon,
type: 'value',
name: '股价(元)',
name: profile.axis.name,
nameLocation: 'middle',
nameGap: 34,
min: PRICE_MIN,
max: PRICE_MAX,
min: profile.axis.min,
max: profile.axis.max,
axisLine: { show: true, lineStyle: { color: PALETTE.axisLine } },
splitLine: { show: false },
...extra,
@@ -102,18 +345,18 @@ const priceAxis = (extra = {}) => ({
const GRID = { left: 78, right: 78, top: LAYOUT.gridTop, bottom: 80 };
function baseOption({
function baseOption(profile, {
title, subtitle, xAxis, yAxis, series, legend, width, height,
tooltipFormatter, extraNote, sourceNote, grid, extraGraphics = [],
tooltipFormatter, extraNote, grid, extraGraphics = [],
}) {
const notes = [sourceNote ?? SOURCE_NOTE];
const notes = [profile.sourceNote];
if (extraNote) notes.push(extraNote);
return {
backgroundColor: '#fff',
title: { ...titleStyle, text: title, subtext: subtitle },
legend: standardLegend(legend, { itemGap: 22 }),
// 三行脚注(来源 + 两行口径)会顶到 height-66,而横轴刻度底边在 height-52。
// 本项目这几张图的横轴带轴名(「股价(元)」),它比刻度再低约 32px;实测
// 本项目这几张图的横轴带轴名(「股价(元)/(港元)」),它比刻度再低约 32px;实测
// grid.bottom=118 时轴名与脚注只剩 7px 净空(同站三行脚注的图通常有 23px),
// 所以统一提到 134 —— 改脚注行数时这里要跟着重算。
grid: { ...GRID, ...grid },
@@ -125,13 +368,13 @@ function baseOption({
};
}
/** 反解 PE 阈值对应的股价:在 pe_base 单调递增的序列上线性插值 */
function peToPrice(rows, pe) {
/** 反解倍数阈值对应的股价:在基准倍数单调递增的序列上线性插值 */
function multToPrice(rows, mult, key) {
for (let i = 1; i < rows.length; i += 1) {
const prev = rows[i - 1];
const cur = rows[i];
if ((prev.pe_base - pe) * (cur.pe_base - pe) <= 0) {
const ratio = (pe - prev.pe_base) / (cur.pe_base - prev.pe_base);
if ((prev[key] - mult) * (cur[key] - mult) <= 0) {
const ratio = (mult - prev[key]) / (cur[key] - prev[key]);
return prev.price + ratio * (cur.price - prev.price);
}
}
@@ -139,13 +382,14 @@ function peToPrice(rows, pe) {
}
/**
* PE 轴上的横向区域带(图 1、图 2 共用)。
* 倍数轴上的横向区域带(图 1、图 2 共用)。
* 必须挂在**第一个系列**上:markArea 在系列自己的分组里、先于该系列图元绘制,
* 而系列分组按声明顺序绘制 —— 挂在后面的系列上会盖住前一条线。
*/
function peBandMarkAreas() {
let low = PE_MIN;
return PE_ZONES.map((zone) => {
function multBandMarkAreas(profile) {
const { metricMin, metricMax } = profile.axis;
let low = metricMin;
return profile.zones.map((zone) => {
const band = [{
yAxis: low,
itemStyle: { color: tint(zone.color, 0.07) },
@@ -159,22 +403,32 @@ function peBandMarkAreas() {
fontWeight: 600,
fontFamily: FONT,
},
}, { yAxis: Math.min(zone.upTo, PE_MAX) }];
}, { yAxis: Math.min(zone.upTo, metricMax) }];
low = zone.upTo;
return band;
});
}
/**
* 各档阈值对应的股价边界(最后一档 upTo 是 Infinity,不参与)。
*
* 缺席的阈值必须**留 null 占位**,不能 filter 掉:filter 会让后面的档位整体前移一格,
* 把「偏贵」的上沿当成「卖出区」的下沿。数据区只到 620 元时(偏贵上沿 625 元在区外)
* 就踩过这个坑,表现为「偏贵」色带一路铺到轴右端、而「卖出区」退化成零宽。
*/
function zoneEdges(profile, rows) {
const key = profile.multiple.scenarios.find((item) => item.hero).key;
return profile.zones.slice(0, -1).map((zone) => multToPrice(rows, zone.upTo, key));
}
/**
* 股价轴上的纵向区域带(图 3 用)。
* 标签贴底:顶部留给折线标签与「现价」竖线的标注,贴底才不会撞。
*/
function priceBandMarkAreas(rows) {
const edges = [PRICE_MIN, ...PE_ZONES
.map((zone) => peToPrice(rows, zone.upTo))
.filter((price) => price !== null), PRICE_MAX];
return PE_ZONES.map((zone, index) => [{
xAxis: edges[index],
function priceBandMarkAreas(profile, rows) {
const edges = [profile.axis.min, ...zoneEdges(profile, rows), profile.axis.max];
return profile.zones.map((zone, index) => [{
xAxis: edges[index] ?? profile.axis.max,
itemStyle: { color: tint(zone.color, 0.08) },
label: {
show: true,
@@ -186,16 +440,18 @@ function priceBandMarkAreas(rows) {
fontWeight: 600,
fontFamily: FONT,
},
}, { xAxis: edges[index + 1] }]);
}, { xAxis: edges[index + 1] ?? profile.axis.max }]);
}
/**
* 股价 → 指标折线。
* 只在 labeledPrices 上的点标数值:30+ 个数据点全标会糊成一片,
* 只在 labels 上的点标数值:20+ 个数据点全标会糊成一片,
* 而这几个点正好是决策价位。
* 不设 z:同图各条线在窗口内不相交(分子随股价线性增长、共享同一个横轴截距),
* 声明顺序即绘制顺序,多设一层 z 只会让 SVG 分组变形。
*/
function priceLine({
name, rows, key, color, width = 2.6, labeledPrices = [],
name, rows, key, color, width = 2.6, labels = [],
unit = '', decimals = 2, position = 'right', distance = 8,
}) {
const line = lineSeries({ name, data: [], color, width });
@@ -204,7 +460,7 @@ function priceLine({
label: { show: false },
data: rows.map((row) => ({
value: [row.price, row[key]],
label: labeledPrices.includes(row.price)
label: labels.includes(row.price)
? {
...line.label,
show: true,
@@ -218,204 +474,288 @@ function priceLine({
}
/**
* 现价竖线:三张图共用。
* 现价竖线:所有图共用。
* label 一律关掉 —— markLine 的标签会**跟着竖线旋转 90°**,长串中文变成一列竖排字,
* 极难读。文字改用 nowGraphics() 以 graphic 单独排,保持水平。
*/
function nowMarkLine() {
function nowMarkLine(now) {
return {
silent: true,
symbol: 'none',
lineStyle: { type: 'dashed', width: 1.6, color: PALETTE.brown },
label: { show: false },
data: [{ xAxis: NOW }],
data: [{ xAxis: now }],
};
}
/** 现价竖线上方的水平标注,位置由 grid 反推(图表尺寸变化时会重算) */
function nowGraphics(width, text) {
const x = GRID.left
+ ((NOW - PRICE_MIN) / (PRICE_MAX - PRICE_MIN)) * (width - GRID.left - GRID.right);
/**
* 现价竖线上方的水平标注,位置由 grid 反推(图表尺寸变化时会重算)。
* 现价贴近右边界时改到竖线左侧右对齐,否则文字会顶出画布 —— 手输价格可能落在整个
* 轴窗口的任意位置,不能像过去只按固定现价那样假设右边一定有地方。
*
* 【纵向位置:画在绘图区**外面**那条留白带里(图例底 ≈107、绘图区顶 145),
* 不是贴绘图区顶边。】贴边时它和绘图区顶行的文字抢位置,低价端必然撞:
* 宁德输 220 元时与「卖出区 >30x」重叠 35×8px、腾讯输 240 港元时与「偏贵 >15x」
* 重叠 16×8px(2026-09-22 用 geometry_check 量出,两家的默认价都恰好躲过了)。
* 挪到绘图区外,顶部就只剩图例(居中、85–107),与本条永不相交;
* 顺带解决了「换个手输价、标注就上下跳」的问题 —— 现在它始终在同一高度。
*/
function nowGraphics(profile, width, now, text) {
const { min, max } = profile.axis;
const x = GRID.left + ((now - min) / (max - min)) * (width - GRID.left - GRID.right);
const flip = x + 8 + text.length * 13 > width - GRID.right;
return [{
type: 'text',
left: Math.round(x + 8),
top: LAYOUT.gridTop + 4,
left: Math.round(flip ? x - 8 : x + 8),
top: LAYOUT.gridTop - 30,
silent: true,
style: { text, fill: PALETTE.brown, fontSize: 12, fontWeight: 600, fontFamily: FONT },
style: {
text,
fill: PALETTE.brown,
fontSize: 12,
fontWeight: 600,
fontFamily: FONT,
...(flip ? { textAlign: 'right' } : {}),
},
}];
}
/**
* 图 1:股价 → 2026E PE 对照 —— 资料库原表第 1 节的图形版
*
* 一条直线就是一个情景:PE 与股价严格成正比,所以三档利润在图上表现为三条**平行**的
* 窄带(±6% 的利润预期落到 PE 上不足 1.7x)。这张图能回答"现价落在哪个区域",
* 但回答不了"贵还是便宜"—— 那取决于利润能不能兑现,属于分母问题,不在本图射程内。
* 现价标注文案。各图格式一致,且指标取不到时不打印 "NaN" —— 那说明现价落在数据区
* 之外(插值无从取值),把原因写出来比一个 NaN 有用。输入框本身也会拦下这种值。
*/
export function sweetCatlPricePeChart(rows, { width, height }) {
const nowPe = metricAt(rows, NOW, 'pe_base');
const hero = priceLine({
name: '中性 964 亿',
rows,
key: 'pe_base',
color: FINANCIAL_COLORS.netProfit,
width: 3.5,
labeledPrices: [265, NOW, 480, 605],
unit: 'x',
position: 'right',
function nowNote(profile, now, label, value, decimals, unit) {
const head = `现价 ${fmtPrice(now)} ${profile.currency}`;
return Number.isFinite(value) ? `${head} = ${label} ${value.toFixed(decimals)}${unit}` : `${head}(超出数据区)`;
}
/** 图 1、图 2 的 tooltip:倍数,两位小数 */
const multipleFormatter = (profile) => (value) => {
const number = Number(Array.isArray(value) ? value[1] : value);
return `${number.toFixed(profile.metric.decimals)}${profile.metric.unit}`;
};
/* ============================================================
* 三个通用 builder
* ============================================================ */
/**
* 图 1:股价 → 倍数对照
*
* 一条直线就是一个情景:分子随股价线性增长(截距是负的非经营资产),所以三档情景在图上
* 是从同一个横轴截距发散出来的一组射线,窗口内近似平行。这张图能回答"现价落在哪个区域",
* 但回答不了"贵还是便宜"—— 那取决于分母(利润/EBIT)能不能兑现,属于图 3 与脚注的事。
*/
function buildMultipleChart(profile, rows, { width, height, params }) {
const now = resolvePrice(profile, params);
const cfg = profile.multiple;
const heroIndex = cfg.scenarios.findIndex((item) => item.hero);
const nowValue = metricAt(rows, now, cfg.scenarios[heroIndex].key);
const lines = cfg.scenarios.map((scenario) => {
const labels = scenario.hero ? refLabels(profile, rows, now, cfg.labels) : [];
const line = priceLine({
name: scenario.name,
rows,
key: scenario.key,
color: scenario.color,
width: scenario.width,
labels,
unit: profile.metric.unit,
position: scenario.label ?? 'right',
});
if (scenario.hero) line.markLine = nowMarkLine(now);
return line;
});
hero.markLine = nowMarkLine();
// markArea 挂在第一个系列上(见 multBandMarkAreas 注释)
lines[0].markArea = { silent: true, data: multBandMarkAreas(profile) };
const bear = {
...lineSeries({ name: '悲观 900 亿', data: [], color: FINANCIAL_COLORS.operatingCashFlow, width: 2.4 }),
label: { show: false },
data: rows.map((row) => [row.price, row.pe_bear]),
};
// markArea 挂在第一个系列上(见 peBandMarkAreas 注释)
bear.markArea = { silent: true, data: peBandMarkAreas() };
const bull = {
...lineSeries({ name: '乐观 1,000 亿', data: [], color: FINANCIAL_COLORS.investingCashFlow, width: 2.4 }),
label: { show: false },
data: rows.map((row) => [row.price, row.pe_bull]),
};
return baseOption({
title: '股价 → 2026E PE 对照',
subtitle: `单位:倍 · 三档利润情景 · 区域底带按中性 964 亿划分 · 现价 ${NOW} 元`,
xAxis: priceAxis(),
yAxis: valueAxis('PE(倍)', { min: PE_MIN, max: PE_MAX, interval: 5 }),
series: [bear, hero, bull],
legend: ['悲观 900 亿', '中性 964 亿', '乐观 1,000 亿'],
return baseOption(profile, {
title: cfg.title,
subtitle: cfg.subtitle({ now }),
xAxis: priceAxis(profile),
yAxis: valueAxis(profile.metric.name, {
min: profile.axis.metricMin,
max: profile.axis.metricMax,
interval: profile.axis.metricInterval,
}),
series: lines,
legend: cfg.legend,
width,
height,
grid: { bottom: 134 },
extraGraphics: nowGraphics(width, `现价 ${NOW} 元 = 中性 ${nowPe.toFixed(2)}x`),
tooltipFormatter: (value) => `${Number(Array.isArray(value) ? value[1] : value).toFixed(2)}x`,
extraNote: 'PE = 市值 ÷ 2026E 归母净利,与股价严格成正比;三档情景只差 ±6% 利润,落到 PE 上不足 1.7x\n'
+ '原表把 15.5–25x 细分为「合理偏低」与「合理」,此处并为一条色带;扣现金 PE 见图 2',
extraGraphics: nowGraphics(profile, width, now, nowNote(profile, now, cfg.nowLabel, nowValue, cfg.nowDecimals, cfg.nowUnit)),
tooltipFormatter: multipleFormatter(profile),
extraNote: cfg.notes({ now, rows }).join('\n'),
});
}
/**
* 图 2:净现金让 PE 便宜了多少 —— 资料库原表第 2 节右图的图形版
* 图 2:非经营资产让倍数便宜了多少
*
* 含现金 PE 与扣现金 PE 是两条**严格平行**的线,间距恒为 净现金 ÷ 2026E 净利
* (约 2.35x)—— 间距与股价无关,但折价占市值的比例随股价下降而放大
* (220 元 22.2% → 700 元 7.0%)。所以这张图要看的不是两条线的高低,
* 各层级口径是**严格平行**的一组线,层间距恒为 该项资产 ÷ 分母 —— 间距与股价无关,
* 但折价占市值的比例随股价下降而放大。所以这张图要看的不是几条线的高低,
* 而是中间那层沙色:它是一块不产生经营利润、但实实在在归股东的资产。
* 宁德只有一层(净现金);腾讯有两层,薄的是净现金、厚的是投资组合 ——
* 只扣现金几乎不动,正是"假阴性"的来源。
*
* 实现说明:ECharts 没有"两个系列之间填充",只能用 stack —— 下段是扣现金 PE、
* 上段是差额,差额段线宽 0、面积填沙色,上沿即含现金 PE。stack 的叠放顺序不能改
* (改了基准就错),于是用 z 控制绘制层级,防止沙色覆盖掉扣现金那条实线。
* 实现说明:ECharts 没有"两个系列之间填充",只能用 stack —— 栈底是最低那条,
* 往上每一层是相邻两层之差;差额段线宽 0、面积填沙色,上沿即上一层口径。
* stack 的叠放顺序不能改(改了基准就错),于是用 z 控制绘制层级,
* 防止沙色覆盖掉栈底那条实线。
*/
export function sweetCatlNetCashChart(rows, { width, height }) {
const gap = rows[0].pe_base - rows[0].pe_ex_cash;
const nowEx = metricAt(rows, NOW, 'pe_ex_cash');
function buildAssetTierChart(profile, rows, { width, height, params }) {
const now = resolvePrice(profile, params);
const cfg = profile.tiers;
const bottom = cfg.lines[cfg.lines.length - 1];
const nowValue = metricAt(rows, now, bottom.key);
const cashLine = {
...lineSeries({ name: '扣现金 PE', data: [], color: PALETTE.brown, width: 3.5 }),
const base = {
...lineSeries({ name: bottom.name, data: [], color: bottom.color, width: bottom.width }),
label: { show: false },
stack: 'pe',
stack: 'tier',
z: 3,
areaStyle: { opacity: 0 },
data: rows.map((row) => [row.price, row.pe_ex_cash]),
data: rows.map((row) => [row.price, row[bottom.key]]),
};
cashLine.markLine = nowMarkLine();
cashLine.markArea = { silent: true, data: peBandMarkAreas() };
base.markLine = nowMarkLine(now);
base.markArea = { silent: true, data: multBandMarkAreas(profile) };
const band = {
...lineSeries({ name: '净现金差额', data: [], color: PALETTE.sand, width: 0 }),
// 自下而上堆差:第 i 层带 = lines[i] − lines[i+1](lines 从上到下)
const bands = [];
for (let i = cfg.lines.length - 2; i >= 0; i -= 1) {
const upper = cfg.lines[i];
const lower = cfg.lines[i + 1];
const style = cfg.bands[cfg.lines.length - 2 - i];
bands.push({
...lineSeries({ name: style.name, data: [], color: style.color, width: 0 }),
label: { show: false },
stack: 'tier',
z: 2,
lineStyle: { color: style.color, width: 0, opacity: 0 },
itemStyle: { color: style.color },
showSymbol: false,
// opacity 必须显式写:areaStyle 的默认 opacity 是 0.7,只给 rgba 底色会被再乘一次
areaStyle: { color: style.color, opacity: 0.7 },
data: rows.map((row) => [row.price, row[upper.key] - row[lower.key]]),
});
}
// 栈顶以上的每一层都要自己的实线(栈只有上沿,没有中间的线)
const overlays = cfg.lines.slice(0, -1).map((tier, index) => ({
...lineSeries({ name: tier.name, data: [], color: tier.color, width: tier.width }),
label: { show: false },
stack: 'pe',
z: 2,
lineStyle: { color: PALETTE.sand, width: 0, opacity: 0 },
itemStyle: { color: PALETTE.sand },
showSymbol: false,
// opacity 必须显式写:areaStyle 的默认 opacity 是 0.7,只给 rgba 底色会被再乘一次
areaStyle: { color: PALETTE.sand, opacity: 0.7 },
data: rows.map((row) => [row.price, row.pe_base - row.pe_ex_cash]),
};
z: 4 + index,
data: rows.map((row) => [row.price, row[tier.key]]),
}));
const gross = {
...lineSeries({ name: '含现金 PE', data: [], color: FINANCIAL_COLORS.investingCashFlow, width: 3 }),
label: { show: false },
z: 4,
data: rows.map((row) => [row.price, row.pe_base]),
};
const pctAt = (price) => metricAt(rows, price, cfg.pctKey);
const bandCtx = bands.map((band, index) => ({
name: band.name,
// bands 自下而上,配置里的 bands 也是自下而上 → 顺序一致
gap: rows[0] ? rows[0][cfg.lines[cfg.lines.length - 2 - index].key] - rows[0][cfg.lines[cfg.lines.length - 1 - index].key] : 0,
}));
return baseOption({
title: '净现金让 PE 便宜了多少',
subtitle: `单位:倍 · 中性 964 亿口径 · 沙色带即净现金 ${NET_CASH.toLocaleString('zh-CN')} 亿元对应的 PE`,
xAxis: priceAxis(),
yAxis: valueAxis('PE(倍)', { min: PE_MIN, max: PE_MAX, interval: 5 }),
series: [cashLine, band, gross],
// 「净现金差额」不进图例(线宽 0,图例色块会是空的),靠副标题与脚注交代
legend: ['含现金 PE', '扣现金 PE'],
return baseOption(profile, {
title: cfg.title,
subtitle: cfg.subtitle({
nonOpText: profile.nonOp.toLocaleString('zh-CN'),
bands: bandCtx,
}),
xAxis: priceAxis(profile),
yAxis: valueAxis(profile.metric.name, {
min: profile.axis.metricMin,
max: profile.axis.metricMax,
interval: profile.axis.metricInterval,
}),
// 栈底 → 差额带 → 各层实线;「差额」不进图例(线宽 0,图例色块会是空的)
series: [base, ...bands, ...overlays],
legend: cfg.lines.map((tier) => tier.name),
width,
height,
grid: { bottom: 134 },
extraGraphics: nowGraphics(width, `现价 ${NOW} 元 = 扣现金 ${nowEx.toFixed(2)}x`),
tooltipFormatter: (value) => `${Number(Array.isArray(value) ? value[1] : value).toFixed(2)}x`,
extraNote: '含现金 PE = 市值 ÷ 2026E 净利;扣现金 PE =(市值 − 净现金)÷ 2026E 净利,即只给主营电池业务定价\n'
+ `两条线严格平行:差额恒为 ${gap.toFixed(2)}x,但占市值比例从 220 元的 22.2% 收窄到 700 元的 7.0% —— 股价越低,这层折价越厚`,
extraGraphics: nowGraphics(profile, width, now, nowNote(profile, now, cfg.nowLabel, nowValue, cfg.nowDecimals, cfg.nowUnit)),
tooltipFormatter: multipleFormatter(profile),
extraNote: cfg.notes({
bands: bandCtx,
dataMin: profile.dataMin,
dataMax: profile.dataMax,
minPct: pctAt(profile.dataMin).toFixed(1),
maxPct: pctAt(profile.dataMax).toFixed(1),
rows,
}).join('\n'),
});
}
/**
* 图 3:任意股价试算 —— 资料库原表第 5 节计算器的图形版
* 图 3:任意股价试算
*
* 计算器要输入一个价格才出结果,图表反过来:220–700 元全档都画出来,任意价位
* 沿竖线直接读。两条线是同一个目标价(605 元 = 2028E 1,400 亿 × 20x)下的三年
* 年化收益,差额即 50% 分红率的贡献。纵轴穿过 0 的位置就是"三年白干"—— 正好
* 是 605 元这条卖点线,所以零线本身就是一条结论。
*
* 纵向底带把估值区域直接画在股价轴上:横带看 PE、竖带看股价,是同一套档位。
* 击球区上沿 270.9 元、买入区上沿 322.9 元、合理区上沿 520.9 元(由 pe_base 反解)。
* 计算器要输入一个价格才出结果,图表反过来:整档价格都画出来,任意价位沿竖线直接读。
* 纵轴穿过 0 的位置就是"三年白干"—— 它正好落在退出价上,所以零线本身就是一条结论。
* 纵向底带把估值区域直接画在股价轴上:横带看倍数、竖带看股价,是同一套档位。
*/
export function sweetCatlAnyPriceChart(rows, { width, height }) {
const hero = priceLine({
name: '含分红年化',
rows,
key: 'cagr_total',
color: FINANCIAL_COLORS.netProfit,
width: 3.5,
labeledPrices: [265, NOW, 450, TARGET_2028],
unit: '%',
decimals: 1,
position: 'top',
distance: 10,
});
hero.markLine = nowMarkLine();
// markArea 挂在第一个系列上,否则会盖住同图上先声明的折线
hero.markArea = { silent: true, data: priceBandMarkAreas(rows) };
function buildAnyPriceChart(profile, rows, { width, height, params }) {
const now = resolvePrice(profile, params);
const cfg = profile.anyPrice;
const heroIndex = cfg.lines.findIndex((item) => item.hero);
const heroSpec = cfg.lines[heroIndex];
const nowValue = metricAt(rows, now, heroSpec.key);
const edges = zoneEdges(profile, rows);
const priceOnly = priceLine({
name: '仅价差年化',
rows,
key: 'cagr_price',
color: FINANCIAL_COLORS.investingCashFlow,
width: 2.6,
labeledPrices: [265, NOW, 450],
unit: '%',
decimals: 1,
position: 'bottom',
distance: 10,
const lines = cfg.lines.map((item) => {
const line = priceLine({
name: item.name,
rows,
key: item.key,
color: item.color,
width: item.width,
labels: refLabels(profile, rows, now, item.labels ?? []),
unit: '%',
decimals: 1,
position: item.position ?? 'right',
distance: item.distance ?? 8,
});
if (item.hero) {
line.markLine = nowMarkLine(now);
// markArea 挂在主角系列上,否则会盖住同图上先声明的折线
line.markArea = { silent: true, data: priceBandMarkAreas(profile, rows) };
}
return line;
});
return baseOption({
title: '任意股价试算:三年预期年化收益',
subtitle: '单位:% · 目标价 605 元(2028E 1,400 亿 × 20x)· 纵带为估值区域',
xAxis: priceAxis(),
yAxis: valueAxis('年化收益(%)', { min: -10, max: 50, interval: 10 }),
series: [hero, priceOnly],
legend: ['含分红年化', '仅价差年化'],
return baseOption(profile, {
title: cfg.title,
subtitle: cfg.subtitle({ now }),
xAxis: priceAxis(profile),
yAxis: valueAxis('年化收益(%)', cfg.yAxis),
series: lines,
legend: cfg.legend,
width,
height,
grid: { bottom: 134 },
extraGraphics: nowGraphics(width, `现价 ${NOW} 元 = 含分红年化 ${metricAt(rows, NOW, 'cagr_total').toFixed(1)}%`),
extraGraphics: nowGraphics(profile, width, now, nowNote(profile, now, cfg.nowLabel, nowValue, cfg.nowDecimals, cfg.nowUnit)),
tooltipFormatter: (value) => `${Number(Array.isArray(value) ? value[1] : value).toFixed(1)}%`,
extraNote: `年化收益 =(${TARGET_2028} ÷ 股价)^(1/3) − 1;含分红口径再乘 (1 + 股息率),股息按当前价每年派现、不复投\n`
+ '纵带边界由 pe_base 反解:击球区上沿 270.9 元 | 买入区上沿 322.9 元 | 合理区上沿 520.9 元',
extraNote: cfg.notes({
currency: profile.currency,
// 边界从数据反解、不写死:改股本/汇率/利润预期后这里跟着走
boundary: profile.zones.slice(0, -1)
.map((zone, index) => (edges[index] === null ? null : `${zone.name} ${edges[index].toFixed(1)}`))
.filter(Boolean).join(' | '),
rows,
}).join('\n'),
});
}
/* ============================================================
* 导出:每家三张图
* ============================================================ */
export const sweetCatlPricePeChart = (rows, options) => buildMultipleChart(CATL, rows, options);
export const sweetCatlNetCashChart = (rows, options) => buildAssetTierChart(CATL, rows, options);
export const sweetCatlAnyPriceChart = (rows, options) => buildAnyPriceChart(CATL, rows, options);
export const sweetTencentPriceEvChart = (rows, options) => buildMultipleChart(TENCENT, rows, options);
export const sweetTencentNonOpChart = (rows, options) => buildAssetTierChart(TENCENT, rows, options);
export const sweetTencentAnyPriceChart = (rows, options) => buildAnyPriceChart(TENCENT, rows, options);
export const SWEET_SPOT_PROFILES = PROFILES;
+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();
},
});
+73 -2
View File
@@ -46,16 +46,23 @@ export function createAppShell(root, { onReload }) {
const layout = element('div', 'app-layout');
const companyNav = element('aside', 'sidebar company-sidebar');
const content = element('main', 'content');
// tab 栏与参数行共处一个 sticky 容器:参数行是独立的一行(不是 tab 栏的一部分),
// 但要和 tab 栏一起钉在顶部。若让两行各自 sticky,参数行就得设「tab 栏高度」的 top
// 偏移,而 tab 栏会换行、窄屏还会横向滚动,高度并不固定;绑在一个容器里天然不会错位。
const tabHeader = element('div', 'tab-header');
const tabs = element('nav', 'tabs');
const params = element('div', 'tab-controls');
params.hidden = true;
tabHeader.append(tabs, params);
const cards = element('div', 'cards');
content.append(tabs, cards);
content.append(tabHeader, cards);
const chartNav = element('aside', 'sidebar chart-sidebar');
layout.append(companyNav, content, chartNav);
const cartBar = element('div', 'cart-bar');
cartBar.hidden = true;
root.append(layout, reloadButton, cartBar);
return { companyNav, tabs, cards, chartNav, cartBar };
return { companyNav, tabs, params, cards, chartNav, cartBar };
}
export function renderCompanies(container, companies, activeCompanyId, onSelect) {
@@ -82,6 +89,70 @@ export function renderTabs(container, tabs, activeTabId, onSelect) {
});
}
/**
* tab 级数值参数控件(目前只有「现价」)—— 由 manifest 的 tab.controls 声明。
*
* 位置:页面里独立的一行,钉在 tab 栏正下方(见 createAppShell 的 .tab-header)。
* 不塞进 tab 栏:它是改数据口径的输入,混在导航按钮里容易被当成 tab 误点。
*
* 回调约定:onChange(id, rawValue) 返回 false 表示这次输入不合法(越界/非数字),
* 输入框标红示意,图表保持原样 —— 校验规则属于控制层,壳层不重复实现。
*/
export function renderTabControls(container, controls, values, onChange) {
// 容器是常驻的,每次整屏重渲染都要清空;否则 resize(200ms 防抖)会叠出多行控件
container.replaceChildren();
container.hidden = !controls?.length;
if (!controls?.length) return;
controls.forEach((control) => {
const field = element('label', 'param-field');
if (control.hint) field.title = control.hint;
field.append(element('span', 'param-label', control.label));
const input = document.createElement('input');
input.className = 'param-input';
input.type = 'number';
input.inputMode = 'decimal';
input.step = String(control.step ?? 1);
input.min = String(control.min);
input.max = String(control.max);
input.value = String(values?.[control.id] ?? control.value);
input.setAttribute('aria-label', `${control.label}${control.unit ?? ''}`);
field.append(input, element('span', 'param-unit', control.unit ?? ''));
// 合法区间常驻显示:输入越界时它变红,用户不必猜上下限
field.append(element('span', 'param-range', `${control.min}–${control.max}`));
const reset = button('重置', {
className: 'param-reset',
onClick: () => {
input.value = String(control.value);
input.dataset.invalid = 'false';
// 值已回到默认,按钮自己进入禁用态(不等下一次 input 事件,否则点完还亮着)
reset.disabled = true;
onChange?.(control.id, input.value, { immediate: true });
input.focus();
},
});
reset.title = `回到默认值 ${control.value}`;
const apply = (immediate = false) => {
const ok = onChange?.(control.id, input.value, { immediate }) !== false;
input.dataset.invalid = String(!ok);
reset.disabled = input.value === String(control.value);
};
input.addEventListener('input', () => apply());
// 回车立即生效,不等防抖(防抖只针对连续输入)
input.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
apply(true);
});
reset.disabled = input.value === String(control.value);
container.append(field, reset);
});
}
export function renderCards(container, charts, activeViews, { onExport, isCarted, onToggleCart, onSelectView }) {
container.replaceChildren();
if (!charts.length) {