Files
smart-fin-chart/CLAUDE.md
T
2026-09-24 17:14:03 +08:00

15 KiB
Raw Blame History

图表开发约定

新建图表时

必须从 theme.js 取共享配置,不要手写等价的值:

import {
  PALETTE, FINANCIAL_COLORS, BRIDGE_COLORS, CHANNEL_COLORS, FONT, axisCommon, periodAxis, titleStyle,
  lineSeries, lineDataWithSafeLabels, barSeries, standardLegend, footnote, watermarks, LAYOUT,
} from '../theme.js';
  • 折线用 lineSeries(),柱状用 barSeries() —— 空心圆点、标签强显等细节已内置
  • 图例用 standardLegend(names),间距用 LAYOUT.legendTop / LAYOUT.gridTop
  • 期间轴用 periodAxis(自带 2026H1 → 26H1、2026H2 → 26全年 简写)

测试

每次只测本次改动的图表,不要跑全量。 这是硬要求,不是建议:

node test/regress.mjs ppmt_ip_share                    # 改了一张,就只测这一张
node test/regress.mjs ppmt_ip_share ppmt_core_margin   # 改了多张,列出来
pnpm test                                              # 全量,只在改了 theme.js / csv.js 等公共模块时才跑

全量跑纯属浪费,而且十几张无关图表的输出会盖住本次改动的真实问题。不传参数时才是全量,所以 pnpm test 保持向后兼容。

它会断言 legend.top、grid.top、折线 symbolSize/borderWidth,并检查 SVG 字体引号。回归测试直接遍历 manifest.json 的 chartCatalog,新增图只要完成 registry 与 manifest 登记就会被测到。

带参数的图要另外跑参数测试。 通用回归永远按默认值渲染,跑不到手输参数那条路径:

pnpm test:param          # 甜蜜击球区「现价」参数:标线位置 + 插值结果(两家公司 6 张图)

手输参数的图还要量几何、而且必须扫全价位。 参数测试只查数值对不对,查不出「标签撞在一起」;而碰撞窗口常常只有几个价位宽 —— 甜蜜击球区实测:现价贴数据区下沿时顶部标注会撞左上角的区域带标签(宁德只有 220–240 会撞,抽查 310/400/700 全绿),现价落进关键价位旁一个数据格时两个数据标签会叠(宁德只在 265±20、腾讯只在 404.5±17):

cp ~/.workbuddy/skills/smart-fin-chart-add-chart/scripts/geometry_check.mjs tmp_geometry.mjs
node tmp_geometry.mjs --sweep sweet-tencent-price-ev sweet-catl-any-price   # 全价位扫描
node tmp_geometry.mjs --w=940 sweet-tencent-price-ev                       # 换画布宽度
rm tmp_geometry.mjs

图表参数(tab 级控件)

可手输的参数在 manifest.json 的 tab 上声明,不是在卡片上 —— 同一 tab 里的图共用一套口径(现价对三张击球区图是同一个事实),卡片级会变成三个各说各话的输入框:

"tabs": [{ "id": "catl", "name": "宁德时代",
  "controls": [{ "id": "price", "type": "number", "label": "现价", "unit": "元",
                 "value": 316.21, "min": 220, "max": 700, "step": 1 }],
  "chartRefs": ["..."] }]
  • 链路:renderTabControls(ui.js,纯 DOM)→ applyTabParam(main.js,校验 + 防抖)→ refreshChartParams(复用实例 setOption,不 dispose 重建)→ 图表 builder 的第三个参数 params。
  • 位置:shell.params 是页面里独立的一行,钉在 tab 栏正下方,与 tab 栏同处一个 sticky 容器 .tab-header(两行各自 sticky 就得给参数行算「tab 栏高度」的偏移,而 tab 栏会换行、窄屏还横向滚动,高度不固定)。没有 controls 的 tab 由 JS 置 hidden,整行含虚线分隔一并消失。改动这里要连带核对 .card 的 scroll-margin-top(桌面 128px / ≤880px 160px,= 页头底边 + 18px 呼吸)。
  • builder 签名写成 (rows, { width, height, params }),取值用 resolvePrice(params) 之类带兜底的解析函数:没有 params 时必须回落到默认值,否则 SSR 回归测试与脚本会拿到 NaN。
  • 可输区间 = 数据区,不是坐标轴:手输值靠相邻行线性插值取值,落在数据区外只能得到 NaN。轴可以比数据区宽一点(留白),输入区间不行。
  • 手输值通常不是数据行,所以那些「只标关键价位」的 labeledPrices 要按数据行命中与否决定是否带上它,并做避让(见 sweet_spot.js 的 refLabels):现价与某个关键价位落在同一个数据格内时只留现价 —— 两个标签数值几乎相同、包围盒会实打实叠在一起。阈值取数据格步长(20)而不是现算像素,因为标签宽约 43px、每元约 1.8px,像素阈值会随轴范围漂。
  • 「现价」这类跟随输入变化的 graphic 标注要画在绘图区上方那条留白带里(top: LAYOUT.gridTop - 30),不要贴绘图区顶边(gridTop + 4):贴边时它和绘图区顶行的文字抢位置,手输值落在低价端必然相撞(宁德 220 元时与「卖出区 >30x」重叠 35×8px)。画在外面只剩居中的图例在它上方,永不相交,顺带消除了「换个价、标注就上下跳」。
  • 纵轴下限要给「区域带名字」那一条留位置。 纵向区域带的名字画在绘图区底边上方 12–25px,若数据线能压到接近下限,线标签就会和带名字重叠(宁德图 3 的「仅价差年化」在 700 元处跌到 -4.7%,轴下限 -10 只留 5.3pp 余量,手输 680 元时「-3.8%」压住「卖出区」22×7px)。改轴下限比挪标签便宜:图 3 的纵轴两家现在都是 -20 起。
  • 默认值的同源方要写在注释里。现价这套:sweet_spot.js 的 profile.priceDefault、manifest 的 controls[].value、build_sweet_spot_catl.py / build_sweet_spot_tencent.py 三处一起改。

右侧纵向图例的图会报 legend.top=middle (期望85),这是预期的,不要去"修"。 那条断言的 85px 基准是给顶部横向图例定的,对 orient: 'vertical' + top: 'middle' 的图不适用(ppmt_ip_share、ppmt_ip_share_history 都会报)。

踩过的坑

优先用 standardLegend(),且不要手工设置 legend.icon。 ECharts 会按系列类型自动生成图例:柱状图为矩形,折线图为「短线 + 空心圆」。需要特殊位置时可扩展 legendStyle,但不得覆盖这一自动类型图标。

label.position 不接受函数。 写了会被静默忽略、退化成默认位置,实测确认过。要让同一系列内不同点朝向不同,只能逐点覆盖 data:{ value, label: { position: 'top' } }。

字体名用单引号。 FONT 里若用双引号,SVG 渲染时会提前闭合 style="..." 属性生成非法 SVG。canvas 看不出来,导出 SVG 才炸。

多折线标签避让:按「朝间隙大的一侧」放,不要按排名奇偶交替。 奇偶交替会让排名相邻的两条线一个朝下一个朝上、相向靠拢,值接近时标签间距被压到小于字高,然后被 hideOverlap 丢弃(表现是「某个数看不到」)。正确做法见 ppmt_margins.js 的 labeledData():比较上下间隙取大者,最高值必朝上、最低值必朝下。

矮柱子的标签会被自动隐藏。 数据跨度大时(如 1.9 ~ 169.1),矮柱标签比柱子还高就不渲染了。barSeries() 已设 label.overflow: 'none' 兜住;手写柱状系列时记得加。

数据口径

底稿与图表数据分层。 data/{company}/rawData/ 是唯一可维护的数据层。当前泡泡玛特的维护入口是其中的 泡泡玛特_财报分析底稿.xlsx:人工或 AI 从 PDF、截图、XLSX 提取的基础值先写入这份底稿,并保留来源文件、工作表和定位信息。data/{company}/*.csv 是图表运行时投影,只能由脚本生成,禁止手工编辑。

维护明细,不维护可推导结果。 收入、门店数、财报科目等基础值应保存;占比、同比、利润率、H2 单半年和分析分类小计应由脚本生成。若财报披露了占比,可作为核对值保留,但不是图表的唯一输入。资产负债表的“类现金/经营类资产/有息负债”等分类以独立规则映射明细科目,不复制成第二套人工数字。

数值默认保留一位小数。 CSV 数据列与图表展示(柱/点标签、tooltip)统一保留一位小数(如 71.1、311.4%),四舍五入;确实需要其他精度时必须在 footnote() 或代码注释里说明原因。

CSV 存四位年份(2026H1),简写只在显示层做。核对数据时无歧义。

利润表的 H2 列是全年累计,不是下半年。 已核对:2025H2 营收 371.2 = 2025H1 的 138.76 + 单半年 232.44。所以 26全年 这类标签指累计值。

但「IP收入和占比」表的 H2 是单半年,不是累计。 同一个 workbook 里两种口径,别想当然。已核对:该表 2025H2 合计 232.44 = 利润表 371.2 − 138.76,五年逐年都吻合。所以这张表的图不能用 periodAxis——它会把 2025H2 渲染成「25全年」,是错的。改用 categoryAxis + 只去世纪前缀的 formatter(v.replace(/^20/, '') → 25H2),见 ppmt_ip_share_history.js。新建图表前先确认数据源用的是哪种口径。

衍生指标标注来源。 非 GAAP 净利率是 非GAAP净利 ÷ 营业收入 算出来的,表里没这行;2020H1/H2 非 GAAP 缺失,用归母净利替代。这类替代必须写进 footnote()。

桥图/瀑布图要验算闭合。 各项占营收比是税前口径,加总到净利率前须乘税盾 (1 - 有效税率);税率变化单独成项。恒等式 净利率 = 税前利润率 × (1-税率),分解为 Δ净 = Δ税前×(1-t₁) − 税前₂×Δt。改完核对首尾误差应 < 0.05pct。

甜蜜击球区板块(股价 ↔ 估值)

菜单 id sweet-spot,两个 tab(catl 宁德时代 / tencent 腾讯控股),各三张图,共用一套通用 builder:

宁德时代 腾讯控股
纵轴口径 PE = 市值 ÷ 2026E 归母净利 EV/EBIT =(市值 − 非经营资产)÷ TTM EBIT
扣什么 类现金 2,263.8 亿(纯现金,准现金) 净现金 581.9 + 上市投资 4,872.0 + 非上市投资 387.9 = 5,841.8 亿(股权,会波动)
甜蜜区锚 13 / 15.5 / 25 / 30x(资料库原表五档) 8 / 10 / 12x(巴菲特原始锚,白送 / 甜点 / 上沿)
数据区 / 输入区 220–700 元 240–640 港元
默认现价 316.21 元(2026-09-15) 426.8 港元(2026-09-17)

两家的口径差异不是随意选的:宁德的类现金是现金,扣掉不改变分子的性质,PE 口径成立;腾讯的「类现金」主体是 5,260 亿投资组合(股权会波动),只扣净现金 581.9 亿几乎不改变倍数(少 0.23x),必须连投资组合一起扣才是经营资产的真实价格 —— 图 2 的三层线(不扣 / 只扣净现金 / 全扣)就是为把「漏扣投资组合会得出超上沿的假阴性」这件事画出来。

  • 代码结构:js/charts/sweet_spot.js 里 PROFILES = { catl, tencent } 两个配置块 + 三个通用 builder(buildMultipleChart / buildAssetTierChart / buildAnyPriceChart),导出 6 个图表函数。加第三家公司不要复制粘贴 builder,照着配置块的字段补齐即可。
  • CSV 由 scripts/build_sweet_spot_catl.py / build_sweet_spot_tencent.py 生成,禁止手工编辑。改股本、汇率、利润预期 → 改脚本常量 → 重跑 → 图表自动跟着走(区域边界是从 CSV 反解的,没写死)。
  • 区域边界的股价必须从数据反解(multToPrice),缺席的阈值留 null 占位不要 filter 掉,否则后面档位整体前移一格(表现为「偏贵」色带铺到轴右端、最贵那档退化成零宽,且不报错)。
  • 验证:node test/sweet_spot_param.mjs(42 项,独立复算公式)+ geometry_check.mjs --sweep(全价位量几何)。

配色

正式方向:轻快消费。 柱线组合图的现金流、利润与同比使用 FINANCIAL_COLORS,渠道结构使用 CHANNEL_COLORS,瀑布图使用 BRIDGE_COLORS(蓝色基准、青绿色正向、珊瑚红负向);不要在图表文件里为相同财务/渠道语义重写十六进制颜色。柱状系列负责分类,折线只保留一条高辨识度强调色。

一张图只留一个视觉主角。 辅助柱使用中等饱和度;避免红、橙、紫等多个强调色同时竞争。分类色要同时拉开色相和明度;折线除颜色外还使用线宽、实虚线、空心圆和标签优先级辅助区分。

柱线组合的标签不能压在柱体端点。 使用 lineDataWithSafeLabels(rows, { lineKey, barKeys }):正值标签在上、负值标签在下,接近柱值时自动增加距离,并配合 ECharts labelLayout 兜底;不要按某个年份写特判。

先分清两件事,别混用同一个门槛。选色的功夫要和系列数成正比——两个系列的图不该选半天。

① 同一张图内的系列要能互相区分。 这条永远要满足,是可读性问题:

  • ≤ 4 个系列:手挑,凭常识就够,不用跑 ΔE
  • 5~9 个系列:手挑后验算一次,两两 > 25
  • ≥ 10 个系列:ΔE 到 ~24 就收手,硬凑 > 25 会把颜色逼得很丑(见 ppmt_ip_share.js 的 11 色谱)

② 跨图撞色只在「同类编码 + 位置紧邻」时才查。 目的是防止读者把两张图的系列对应起来,所以:

  • 折线图 vs 紧邻的折线图、或两图共用同一套分类色谱 → 要查
  • 柱+线双轴图 vs 饼图 → 不用查,编码类型本身就区分开了
  • 隔着好几张、主题无关的图 → 不用查

不要拿整页累积的所有颜色当避让集合。 这是个只会越来越紧的棘轮:每加一张图就多几个色要避让。实测过,对整页 26 色都要求 ΔE > 25,可行域只剩 18%(只对紧邻图要求则有 39%);而且现有配色自己就有 13 对不达标——星星人/orange ΔE 6.5、DIMOO/green 7.5、MONSTERS/red 10.4、blue/navy 14.4。拿一条自己都没遵守的规则去卡新图,结果就是在两个系列的图上白耗三轮验算。

不要用贪心/模拟退火/随机搜索去"解"配色。 太耗时,且算出来的色往往不好看(机器只顾拉开距离,不管协调)。手写一条色相谱再验算:

  1. 按色相环顺序挑(如 红→橙→金→橄榄→翡翠→青→蓝→紫),残差项("其他"/"外采")用中性灰
  2. 同色系连续多项靠明暗交替拉开(深蓝 → 浅蓝紫 → 紫 → 浅兰紫),不然挤在一起分不清
  3. 只对不达标的几项微调,一两轮收手

ppmt_margins.js 顶部记着当初选深青/芥黄/茄紫/玫红的过程。注意那张图是「4 条折线 + 同页有别的折线图」,正属于 ② 要查的情形;别把它的严格度套到所有图上。它当年淘汰 slate/cyan/indigo 的理由是「离 blue/navy 仅 ΔE 17~19」,可 blue 和 navy 自己才差 14.4——这个先例本身就偏严,别照抄。

脚注

footnote() 单行约 60 字符封顶(1200px 画布),超了用 \n 手动断行。结尾统一 | @Joey知行记。

水印文案在 theme.js 的 WATERMARK_TEXT,改一处全局生效。