Files
2026-09-24 17:14:03 +08:00

155 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 图表开发约定
## 新建图表时
必须从 `theme.js` 取共享配置,不要手写等价的值:
```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全年` 简写)
## 测试
**每次只测本次改动的图表,不要跑全量。** 这是硬要求,不是建议:
```bash
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 登记就会被测到。
**带参数的图要另外跑参数测试。** 通用回归永远按默认值渲染,跑不到手输参数那条路径:
```bash
pnpm test:param # 甜蜜击球区「现价」参数:标线位置 + 插值结果(两家公司 6 张图)
```
**手输参数的图还要量几何、而且必须扫全价位。** 参数测试只查数值对不对,查不出「标签撞在一起」;而碰撞窗口常常只有几个价位宽 —— 甜蜜击球区实测:现价贴数据区下沿时顶部标注会撞左上角的区域带标签(宁德只有 220–240 会撞,抽查 310/400/700 全绿),现价落进关键价位旁一个数据格时两个数据标签会叠(宁德只在 265±20、腾讯只在 404.5±17):
```bash
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 里的图共用一套口径(现价对三张击球区图是同一个事实),卡片级会变成三个各说各话的输入框:
```json
"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`,改一处全局生效。