155 lines
15 KiB
Markdown
155 lines
15 KiB
Markdown
# 图表开发约定
|
||
|
||
## 新建图表时
|
||
|
||
必须从 `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`,改一处全局生效。
|