feat: 构建泡泡玛特相关图表
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
|||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# V2 运行时数据(SQLite + 本地媒体)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# 反编译参考资料(约 40 个 bundle),设计结论已写入 docs/extension/plan.md §2
|
||||||
|
reference/
|
||||||
|
|
||||||
|
# Node(extension / studio / packages)
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.output/
|
||||||
|
.wxt/
|
||||||
|
.pnpm-store/
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# 图表开发约定
|
||||||
|
|
||||||
|
## 新建图表时
|
||||||
|
|
||||||
|
必须从 `theme.js` 取共享配置,不要手写等价的值:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, periodAxis, titleStyle,
|
||||||
|
lineSeries, 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 # 改了多张,列出来
|
||||||
|
npm test # 全量,只在改了 theme.js / csv.js 等公共模块时才跑
|
||||||
|
```
|
||||||
|
|
||||||
|
全量跑纯属浪费,而且十几张无关图表的输出会盖住本次改动的真实问题。不传参数时才是全量,所以 `npm test` 保持向后兼容。
|
||||||
|
|
||||||
|
它会断言 `legend.top`、`grid.top`、折线 `symbolSize`/`borderWidth`,并检查 SVG 字体引号。加新图记得同步往 `regress.mjs` 顶部的 `all` 数组加一行,否则不会被测到。
|
||||||
|
|
||||||
|
**右侧纵向图例的图会报 `legend.top=middle (期望85)`,这是预期的,不要去"修"。** 那条断言的 85px 基准是给顶部横向图例定的,对 `orient: 'vertical'` + `top: 'middle'` 的图不适用(`ppmt_ip_share`、`ppmt_ip_share_history` 都会报)。
|
||||||
|
|
||||||
|
## 踩过的坑
|
||||||
|
|
||||||
|
**不要用 `...legendStyle`。** 它遗留 `top: 62` 且曾带 `icon: 'roundRect'`——后者会盖掉 ECharts 按系列类型自动生成的图例(折线本该是「短线+空心圆」)。用 `standardLegend()`。
|
||||||
|
|
||||||
|
**`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'` 兜住;手写柱状系列时记得加。
|
||||||
|
|
||||||
|
## 数据口径
|
||||||
|
|
||||||
|
**数值默认保留一位小数。** 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。
|
||||||
|
|
||||||
|
## 配色
|
||||||
|
|
||||||
|
先分清两件事,别混用同一个门槛。**选色的功夫要和系列数成正比**——两个系列的图不该选半天。
|
||||||
|
|
||||||
|
**① 同一张图内的系列要能互相区分。** 这条永远要满足,是可读性问题:
|
||||||
|
|
||||||
|
- ≤ 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`,改一处全局生效。
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# 财报图表 · Joey 谨制
|
||||||
|
|
||||||
|
基于 **ECharts 5.5** 的财报数据可视化项目,模仿"方木博制"风格。
|
||||||
|
|
||||||
|
修改 CSV 数据,刷新页面即可更新图表;每张图表都可独立导出为高清 PNG。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 启动本地服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方案 A:Python 3(Mac/Linux 自带)
|
||||||
|
python3 -m http.server 8899
|
||||||
|
|
||||||
|
# 方案 B:Node.js(需要先安装 Node)
|
||||||
|
npx http-server -p 8899
|
||||||
|
|
||||||
|
# 方案 C:PHP(Mac 自带)
|
||||||
|
php -S localhost:8899
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 打开浏览器
|
||||||
|
|
||||||
|
访问 **http://localhost:8899**,会看到 5 张示例图表和 11 张泡泡玛特财报图表:
|
||||||
|
|
||||||
|
**示例图表:**
|
||||||
|
1. **存货、营收与存货周转天数** — 双柱 + 折线,双 Y 轴
|
||||||
|
2. **利润率与费用率拆解** — 四条率线对比
|
||||||
|
3. **毛利率与非国际净利率** — 两条主要率线
|
||||||
|
4. **营收到利润的增速对比** — 四条增速线
|
||||||
|
5. **人民币汇率两种口径** — 中间价 vs 在岸即期,带说明卡片
|
||||||
|
|
||||||
|
**泡泡玛特财报分析:**
|
||||||
|
6. **资产结构演变** — 堆叠柱状图展示类现金、应收账款、投资、经营资产
|
||||||
|
7. **盈利能力分析** — 营收、毛利、净利润及毛利率变化
|
||||||
|
8. **主要 IP 收入趋势** — 多条折线展示 MOLLY、SKULLPANDA、CRYBABY 等 IP 表现
|
||||||
|
9. **品类收入结构** — 毛绒、手办、MEGA、衍生品的占比和增长
|
||||||
|
10. **利润流向(2026H1)** — 桑基图,营收拆到成本、费用、净利
|
||||||
|
11. **区域渠道同比增速** — 分区域 × 渠道的增速对比
|
||||||
|
12. **营收到利润的增速对比** — 泡泡玛特口径
|
||||||
|
13. **利润率与费用率拆解** — 毛利率、销售/管理费用率、非 GAAP 净利率
|
||||||
|
14. **净利率同比拆解** — 瀑布图,含税盾换算与税率单列项
|
||||||
|
15. **核心利润及同比增速** — 柱 + 折线
|
||||||
|
16. **核心利润率** — 毛利率 − 销售费用率 − 管理费用率
|
||||||
|
17. **主要 IP 收入占比(2026H1)** — 饼图,右侧图例带收入与占比
|
||||||
|
18. **各 IP 半年收入占比(21H1-26H1)** — 归一化堆叠柱状图,看收入结构变迁
|
||||||
|
19. **除 THE MONSTERS 外的 IP 收入及同比** — 柱 + 折线双轴,剔掉爆款看其余盘子
|
||||||
|
|
||||||
|
### 3. 修改数据
|
||||||
|
|
||||||
|
编辑 `data/*.csv`,保存后点击页面右上角的 **「重新加载数据」** 按钮。
|
||||||
|
|
||||||
|
CSV 格式:首行是表头,逗号分隔,数字列会自动转换。示例:
|
||||||
|
|
||||||
|
```csv
|
||||||
|
period,inventory,revenue,turnover_days
|
||||||
|
22H1,9.6,23.6,160
|
||||||
|
22年,8.7,46.2,154
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 导出图表
|
||||||
|
|
||||||
|
每张图表右上角都有 **「导出 PNG」** 按钮,点击即可下载 2 倍分辨率的图片(适合微信公众号等场景)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
echart-demo/
|
||||||
|
├── index.html # 主页面
|
||||||
|
├── data/ # 数据文件
|
||||||
|
│ ├── inventory.csv # 图 1-5:示例数据
|
||||||
|
│ ├── margin.csv
|
||||||
|
│ ├── growth.csv
|
||||||
|
│ ├── fx.csv
|
||||||
|
│ ├── ppmt.xlsx # 泡泡玛特原始财报
|
||||||
|
│ └── ppmt_*.csv # 图 6-16:从 xlsx 提取的各口径数据
|
||||||
|
├── js/
|
||||||
|
│ ├── main.js # 入口:CHARTS 清单、渲染、导出 PNG
|
||||||
|
│ ├── csv.js # CSV 解析工具
|
||||||
|
│ ├── theme.js # 主题:配色、字体、水印、共享系列构造器
|
||||||
|
│ └── charts/ # 一图一文件,共 16 张
|
||||||
|
├── test/
|
||||||
|
│ └── regress.mjs # 回归测试:SSR 渲染 16 张图 + 样式规范断言
|
||||||
|
└── css/
|
||||||
|
└── style.css # 页面样式
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
用 ECharts 的 SSR 模式把 16 张图渲染成 SVG,断言:
|
||||||
|
|
||||||
|
- `legend.top` = 85、`grid.top` = 145(统一间距)
|
||||||
|
- 折线 `symbolSize` = 9、`borderWidth` = 2.5(空心圆点规范)
|
||||||
|
- SVG 属性未被字体名里的引号提前闭合
|
||||||
|
|
||||||
|
加新图后往 `regress.mjs` 顶部的 `all` 数组加一行,否则不会被测到。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设计原则
|
||||||
|
|
||||||
|
### 统一的视觉语言
|
||||||
|
|
||||||
|
所有图表共用 `js/theme.js` 的配色、字体、坐标轴样式,确保视觉一致性:
|
||||||
|
|
||||||
|
- **配色**:低饱和莫兰迪系(棕色、沙色、珊瑚红、海军蓝等)
|
||||||
|
- **字体**:苹方 / 微软雅黑,中文友好
|
||||||
|
- **坐标轴**:只留浅灰网格线,去掉轴线的存在感
|
||||||
|
- **水印**:全画布平铺"Joey 谨制",旋转 20°,透明度 0.10
|
||||||
|
|
||||||
|
### 数据标签全标注
|
||||||
|
|
||||||
|
每个数据点都显示标签,避免读者"猜数值"。密集时上下交错放置(见图 2),不用 hover tooltip 替代。
|
||||||
|
|
||||||
|
### 底部注释
|
||||||
|
|
||||||
|
重要的计算口径、数据来源写在图表底部(用 `graphic.text`),不依赖外部文档。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
**Q:为什么必须用本地服务器,不能直接双击 `index.html`?**
|
||||||
|
A:浏览器的同源策略禁止 `file://` 协议读取 CSV,会报 CORS 错误。启动服务器后用 `http://` 访问即可。
|
||||||
|
|
||||||
|
**Q:修改 CSV 后刷新页面,图表没变化?**
|
||||||
|
A:点击页面右上角的 **「重新加载数据」** 按钮。普通刷新可能吃到浏览器缓存,按钮会加时间戳绕过缓存。
|
||||||
|
|
||||||
|
**Q:如何添加新图表?**
|
||||||
|
A:
|
||||||
|
1. 在 `data/` 下创建新 CSV
|
||||||
|
2. 在 `js/charts/` 下创建新 `.js`,从 `theme.js` 取共享配置(`lineSeries()`、`barSeries()`、`standardLegend()`、`periodAxis`),不要手写等价值
|
||||||
|
3. 在 `js/main.js` 的 `CHARTS` 数组里加一行
|
||||||
|
4. 在 `index.html` 加一个 `<div class="card">` 容器
|
||||||
|
5. 在 `test/regress.mjs` 的 `all` 数组里加一行,然后跑 `npm test`
|
||||||
|
|
||||||
|
**Q:能改水印文字吗?**
|
||||||
|
A:编辑 `js/theme.js` 的 `WATERMARK_TEXT` 常量。
|
||||||
|
|
||||||
|
**Q:能改配色吗?**
|
||||||
|
A:编辑 `js/theme.js` 的 `PALETTE` 对象,所有图表自动同步。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术细节
|
||||||
|
|
||||||
|
- **ECharts 5.5.1**:从 jsDelivr CDN 加载,无需 npm install
|
||||||
|
- **ES6 Modules**:用原生 `<script type="module">`,无构建步骤
|
||||||
|
- **Canvas 渲染**:页面用 canvas(性能好),导出时用 ECharts 自带的 `getDataURL` 生成 PNG
|
||||||
|
- **响应式**:window resize 时重绘(水印数量跟着画布尺寸变化)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
本项目代码采用 MIT 协议,数据仅供示例。
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
:root {
|
||||||
|
--ink: #333;
|
||||||
|
--grey: #8c8c8c;
|
||||||
|
--line: #e8e8e8;
|
||||||
|
--bg: #f5f4f1;
|
||||||
|
--accent: #6b4a2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 32px 24px 64px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--grey);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s, color .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-reload {
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-reload:hover {
|
||||||
|
opacity: .88;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto 28px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, .06);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-bar .src {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--grey);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-bar button { margin-left: auto; }
|
||||||
|
|
||||||
|
.chart { width: 100%; }
|
||||||
|
|
||||||
|
.chart-error {
|
||||||
|
padding: 40px;
|
||||||
|
color: #c0392b;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>财报图表 · Joey 谨制</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>财报图表</h1>
|
||||||
|
<p>改 <code>data/*.csv</code> 后点「重新加载」即可更新</p>
|
||||||
|
<button id="btn-reload">重新加载数据</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/inventory.csv</span>
|
||||||
|
<button data-export="chart-inventory" data-filename="存货与周转天数">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-inventory" style="height:620px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/margin.csv</span>
|
||||||
|
<button data-export="chart-margin" data-filename="利润率与费用率拆解">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-margin" style="height:620px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/margin.csv(毛利率 + 净利率两列)</span>
|
||||||
|
<button data-export="chart-twoline" data-filename="毛利率与非国际净利率">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-twoline" style="height:620px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/growth.csv</span>
|
||||||
|
<button data-export="chart-growth" data-filename="营收到利润的增速对比">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-growth" style="height:700px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/fx.csv</span>
|
||||||
|
<button data-export="chart-fx" data-filename="人民币汇率两种口径">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-fx" style="height:780px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="margin: 48px auto; max-width: 1180px; border: none; border-top: 2px solid #e0ddd5;">
|
||||||
|
|
||||||
|
<header style="margin-top: 32px;">
|
||||||
|
<h1>📊 泡泡玛特财报分析</h1>
|
||||||
|
<p>基于 <code>data/ppmt.xlsx</code> 自动生成</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/ppmt_assets.csv</span>
|
||||||
|
<button data-export="chart-ppmt-assets" data-filename="泡泡玛特资产结构">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-ppmt-assets" style="height:680px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/ppmt_profit.csv</span>
|
||||||
|
<button data-export="chart-ppmt-profit" data-filename="泡泡玛特盈利能力">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-ppmt-profit" style="height:680px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/ppmt_ip.csv</span>
|
||||||
|
<button data-export="chart-ppmt-ip" data-filename="泡泡玛特IP收入趋势">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-ppmt-ip" style="height:720px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/ppmt_category.csv</span>
|
||||||
|
<button data-export="chart-ppmt-category" data-filename="泡泡玛特品类结构">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-ppmt-category" style="height:680px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/ppmt_sankey_2026h1.csv</span>
|
||||||
|
<button data-export="chart-ppmt-sankey" data-filename="泡泡玛特利润流向2026H1">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-ppmt-sankey" style="height:800px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/ppmt_region_channel_yoy.csv</span>
|
||||||
|
<button data-export="chart-ppmt-region-yoy" data-filename="泡泡玛特区域渠道同比增速">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-ppmt-region-yoy" style="height:620px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/ppmt_growth.csv</span>
|
||||||
|
<button data-export="chart-ppmt-growth" data-filename="泡泡玛特营收到利润增速对比">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-ppmt-growth" style="height:700px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/ppmt_margins.csv</span>
|
||||||
|
<button data-export="chart-ppmt-margins" data-filename="泡泡玛特利润率与费用率拆解">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-ppmt-margins" style="height:680px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/ppmt_margin_bridge.csv</span>
|
||||||
|
<button data-export="chart-ppmt-bridge" data-filename="泡泡玛特净利率同比拆解">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-ppmt-bridge" style="height:700px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/ppmt_core_profit.csv</span>
|
||||||
|
<button data-export="chart-ppmt-core" data-filename="泡泡玛特核心利润及同比增速">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-ppmt-core" style="height:720px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/ppmt_core_margin.csv</span>
|
||||||
|
<button data-export="chart-ppmt-core-margin" data-filename="泡泡玛特核心利润率">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-ppmt-core-margin" style="height:680px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/ppmt_ip_share_2026h1.csv</span>
|
||||||
|
<button data-export="chart-ppmt-ip-share" data-filename="泡泡玛特IP收入占比2026H1">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-ppmt-ip-share" style="height:720px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/ppmt_ip_share_history.csv</span>
|
||||||
|
<button data-export="chart-ppmt-ip-share-history" data-filename="泡泡玛特各IP半年收入占比">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-ppmt-ip-share-history" style="height:700px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-bar">
|
||||||
|
<span class="src">data/ppmt_ip_ex_monsters.csv</span>
|
||||||
|
<button data-export="chart-ppmt-ip-ex-monsters" data-filename="泡泡玛特除MONSTERS外IP收入及同比">导出 PNG</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="chart-ppmt-ip-ex-monsters" style="height:700px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, categoryAxis, titleStyle,
|
||||||
|
lineSeries, footnote, watermarks,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/** 圆角卡片:ECharts 的 graphic.group + rect + 多段文字 */
|
||||||
|
function infoCard({ left, top, width, title, lines, fill, stroke, titleColor }) {
|
||||||
|
const children = [
|
||||||
|
{
|
||||||
|
type: 'rect',
|
||||||
|
shape: { x: 0, y: 0, width, height: 108, r: 8 },
|
||||||
|
style: { fill, stroke, lineWidth: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
style: {
|
||||||
|
x: 20, y: 20,
|
||||||
|
text: title,
|
||||||
|
fill: titleColor,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontFamily: FONT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
children.push({
|
||||||
|
type: 'text',
|
||||||
|
style: {
|
||||||
|
x: 20, y: 52 + i * 22,
|
||||||
|
text: line,
|
||||||
|
fill: '#555',
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontFamily: FONT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { type: 'group', left, top, silent: true, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图 5:人民币兑美元两种口径对比
|
||||||
|
* 只有两个时间点,重点在文字说明和端点标注,不在曲线本身
|
||||||
|
*/
|
||||||
|
export function fxChart(rows, { width, height }) {
|
||||||
|
const stages = rows.map((r) => r.stage);
|
||||||
|
const mid = rows.map((r) => r.mid_price);
|
||||||
|
const spot = rows.map((r) => r.onshore_spot);
|
||||||
|
|
||||||
|
// label.position 不接受函数(写函数会被静默忽略,退化成默认位置),
|
||||||
|
// 想让首尾点位置不同只能逐点覆盖:起点标在上方,末点标在右侧
|
||||||
|
const endpointLabels = (values) =>
|
||||||
|
values.map((v, i) => ({
|
||||||
|
value: v,
|
||||||
|
label: { position: i === values.length - 1 ? 'right' : 'top' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 偏离点数 = (中间价 - 即期)× 10000
|
||||||
|
const gapStart = Math.round((mid[0] - spot[0]) * 10000);
|
||||||
|
const gapEnd = Math.round((mid[1] - spot[1]) * 10000);
|
||||||
|
const midGain = Math.round((mid[0] - mid[1]) * 10000);
|
||||||
|
const spotGain = Math.round((spot[0] - spot[1]) * 10000);
|
||||||
|
const midPct = (((mid[0] - mid[1]) / mid[0]) * 100).toFixed(1);
|
||||||
|
const spotPct = (((spot[0] - spot[1]) / spot[0]) * 100).toFixed(1);
|
||||||
|
|
||||||
|
const cardWidth = 340;
|
||||||
|
const cardTop = 132;
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: '人民币兑美元的两种「升值」口径:中间价 vs 在岸即期',
|
||||||
|
subtext: '口径区别在「谁定的价」:中间价是央行每日公布的锚,即期是市场在锚附近交易出来的成交价',
|
||||||
|
top: 20,
|
||||||
|
},
|
||||||
|
grid: { left: 96, right: 110, top: 340, bottom: 88 },
|
||||||
|
tooltip: { trigger: 'axis', textStyle: { fontFamily: FONT } },
|
||||||
|
xAxis: {
|
||||||
|
...categoryAxis,
|
||||||
|
boundaryGap: ['12%', '12%'],
|
||||||
|
data: stages,
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: 'USDCNY(数值越小=人民币越强)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 62,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: 6.75,
|
||||||
|
max: 7.05,
|
||||||
|
interval: 0.1,
|
||||||
|
axisLabel: {
|
||||||
|
...axisCommon.axisLabel,
|
||||||
|
formatter: (v) => v.toFixed(2),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
...lineSeries({
|
||||||
|
name: '中间价',
|
||||||
|
data: mid,
|
||||||
|
color: '#3b6ea5',
|
||||||
|
formatter: (p) => p.value.toFixed(4),
|
||||||
|
}),
|
||||||
|
data: endpointLabels(mid),
|
||||||
|
symbolSize: 11,
|
||||||
|
itemStyle: { color: '#3b6ea5' },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#3b6ea5',
|
||||||
|
fontFamily: FONT,
|
||||||
|
distance: 10,
|
||||||
|
formatter: (p) => p.value.toFixed(4),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...lineSeries({
|
||||||
|
name: '在岸即期',
|
||||||
|
data: spot,
|
||||||
|
color: PALETTE.coral,
|
||||||
|
formatter: (p) => p.value.toFixed(4),
|
||||||
|
}),
|
||||||
|
data: endpointLabels(spot),
|
||||||
|
symbolSize: 11,
|
||||||
|
itemStyle: { color: PALETTE.coral },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: PALETTE.coral,
|
||||||
|
fontFamily: FONT,
|
||||||
|
distance: 10,
|
||||||
|
formatter: (p) => p.value.toFixed(4),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
|
||||||
|
infoCard({
|
||||||
|
left: 88, top: cardTop, width: cardWidth,
|
||||||
|
title: '中间价(央行公布的「锚」)',
|
||||||
|
lines: [
|
||||||
|
'每交易日 9:15 由央行公布:做市商报价',
|
||||||
|
'加权+逆周期因子,是当日波动中心与政策信号',
|
||||||
|
],
|
||||||
|
fill: '#eef4fa', stroke: '#c7dcf0', titleColor: '#2f5f8f',
|
||||||
|
}),
|
||||||
|
infoCard({
|
||||||
|
left: 88 + cardWidth + 24, top: cardTop, width: cardWidth,
|
||||||
|
title: '在岸即期(市场成交价)',
|
||||||
|
lines: [
|
||||||
|
'银行间外汇市场实时成交形成,16:30 收盘价',
|
||||||
|
'最常被引用;每日只能在中间价 ±2% 区间内波动',
|
||||||
|
],
|
||||||
|
fill: '#fdf1ed', stroke: '#f5d3c8', titleColor: '#c05a45',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 图例改成两行文字说明,信息量比色块图例大
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
left: 108, top: 272, silent: true,
|
||||||
|
style: {
|
||||||
|
text: `● 中间价:${mid[0]} → ${mid[1]},累计升值 ${midGain}点(+${midPct}%)`,
|
||||||
|
fill: '#3b6ea5', fontSize: 14, fontFamily: FONT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
left: 108, top: 296, silent: true,
|
||||||
|
style: {
|
||||||
|
text: `● 在岸即期:${spot[0]} → ${spot[1]},累计升值 ${spotGain}点(+${spotPct}%)`,
|
||||||
|
fill: PALETTE.coral, fontSize: 14, fontFamily: FONT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
footnote(
|
||||||
|
`注:涨幅差${midGain - spotGain}点=期初偏离${gapStart}−期末偏离${gapEnd}(即期偏强收窄)|` +
|
||||||
|
'汇兑重估用市场汇率而非中间价|数据:新华财经|Joey 谨制'
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { col } from '../csv.js';
|
||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, legendStyle,
|
||||||
|
lineSeries, footnote, watermarks, LAYOUT,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图 4:营收到利润的增速对比 —— 四条线看"利润弹性"
|
||||||
|
* 净利增速跑在最上面、成本增速在最下面,中间夹着毛利和营收
|
||||||
|
*/
|
||||||
|
export function growthChart(rows, { width, height }) {
|
||||||
|
const pct = (p) => `${p.value}%`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: { ...titleStyle, text: '营收到利润的增速对比(半年与年度)' },
|
||||||
|
legend: {
|
||||||
|
...legendStyle,
|
||||||
|
top: LAYOUT.legendTop,
|
||||||
|
itemGap: 34,
|
||||||
|
data: ['非国际归母净利', '毛利润', '营业收入', '营业成本'].map(name => ({
|
||||||
|
name,
|
||||||
|
icon: 'emptyCircle', // 全部折线图
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
grid: { left: 84, right: 56, top: LAYOUT.gridTop, bottom: 64 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
valueFormatter: (v) => `${v}%`,
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
},
|
||||||
|
xAxis: { ...categoryAxis, data: col(rows, 'period') },
|
||||||
|
yAxis: {
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '同比增速(%)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 58,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: -45,
|
||||||
|
max: 400,
|
||||||
|
interval: 100,
|
||||||
|
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
lineSeries({
|
||||||
|
name: '非国际归母净利',
|
||||||
|
data: col(rows, 'non_gaap_net_profit'),
|
||||||
|
color: PALETTE.red,
|
||||||
|
labelPosition: 'top',
|
||||||
|
formatter: pct,
|
||||||
|
width: 3.5,
|
||||||
|
}),
|
||||||
|
lineSeries({
|
||||||
|
name: '毛利润',
|
||||||
|
data: col(rows, 'gross_profit'),
|
||||||
|
color: PALETTE.coral,
|
||||||
|
labelPosition: 'top',
|
||||||
|
formatter: pct,
|
||||||
|
}),
|
||||||
|
lineSeries({
|
||||||
|
name: '营业收入',
|
||||||
|
data: col(rows, 'revenue'),
|
||||||
|
color: PALETTE.navy,
|
||||||
|
labelPosition: 'bottom',
|
||||||
|
formatter: pct,
|
||||||
|
}),
|
||||||
|
lineSeries({
|
||||||
|
name: '营业成本',
|
||||||
|
data: col(rows, 'cost'),
|
||||||
|
color: PALETTE.sage,
|
||||||
|
labelPosition: 'top',
|
||||||
|
formatter: pct,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote('示例数据 | @思考的Joey'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { col } from '../csv.js';
|
||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, legendStyle,
|
||||||
|
lineSeries, footnote, watermarks, LAYOUT,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图 1:双柱 + 折线,左右双 Y 轴
|
||||||
|
* 存货/营收走左轴(亿元),周转天数走右轴(天)
|
||||||
|
*/
|
||||||
|
export function inventoryChart(rows, { width, height }) {
|
||||||
|
const periods = col(rows, 'period');
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: { ...titleStyle, text: '存货、营收与存货周转天数(半年与年度)' },
|
||||||
|
legend: {
|
||||||
|
...legendStyle,
|
||||||
|
top: LAYOUT.legendTop,
|
||||||
|
data: [
|
||||||
|
{ name: '存货', icon: 'rect' },
|
||||||
|
{ name: '营收', icon: 'rect' },
|
||||||
|
{ name: '存货周转天数', icon: 'emptyCircle' }, // 折线
|
||||||
|
],
|
||||||
|
},
|
||||||
|
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
},
|
||||||
|
xAxis: { ...categoryAxis, data: periods },
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '金额(亿元)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 52,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: 0,
|
||||||
|
max: 500,
|
||||||
|
interval: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '同比增速(%)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 58,
|
||||||
|
nameRotate: -90,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: -5,
|
||||||
|
max: 220,
|
||||||
|
interval: 50,
|
||||||
|
axisLabel: {
|
||||||
|
...axisCommon.axisLabel,
|
||||||
|
formatter: '{value}天',
|
||||||
|
},
|
||||||
|
splitLine: { show: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '存货',
|
||||||
|
type: 'bar',
|
||||||
|
data: col(rows, 'inventory'),
|
||||||
|
barWidth: 22,
|
||||||
|
barGap: '15%',
|
||||||
|
itemStyle: { color: PALETTE.brown },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
color: PALETTE.ink,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontFamily: FONT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '营收',
|
||||||
|
type: 'bar',
|
||||||
|
data: col(rows, 'revenue'),
|
||||||
|
barWidth: 22,
|
||||||
|
itemStyle: { color: PALETTE.sand },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
color: PALETTE.ink,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...lineSeries({
|
||||||
|
name: '存货周转天数',
|
||||||
|
data: col(rows, 'turnover_days'),
|
||||||
|
color: PALETTE.coral,
|
||||||
|
labelPosition: 'bottom',
|
||||||
|
formatter: (p) => `${p.value}天`,
|
||||||
|
}),
|
||||||
|
yAxisIndex: 1,
|
||||||
|
z: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote(
|
||||||
|
'周转天数=平均存货÷当期销售成本×期间天数(半年181/年度365,期初=上一年末,日历天数口径);' +
|
||||||
|
'与公司披露一致:23年133/24年102/25年123/26H1 201天 | @思考的Joey'
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { col } from '../csv.js';
|
||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, legendStyle,
|
||||||
|
lineSeries, footnote, watermarks, LAYOUT,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图 2:四条率线拆解
|
||||||
|
* 毛利率在上方独立成带,三条费用/净利率在下方交织 —— 标签交替上下放,避免叠字
|
||||||
|
*/
|
||||||
|
export function marginChart(rows, { width, height }) {
|
||||||
|
const periods = col(rows, 'period');
|
||||||
|
const pct = (p) => `${p.value}%`;
|
||||||
|
|
||||||
|
// 交替上下:偶数点在上,奇数点在下。原图就是这么处理密集标签的
|
||||||
|
const alternate = (base) => (p) => (p.dataIndex % 2 === 0 ? base : base);
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: { ...titleStyle, text: '利润率与费用率拆解(半年与年度)' },
|
||||||
|
legend: {
|
||||||
|
...legendStyle,
|
||||||
|
top: LAYOUT.legendTop,
|
||||||
|
data: ['毛利率', '销售费用率', '管理费用率', '非国际归母净利率'].map(name => ({
|
||||||
|
name,
|
||||||
|
icon: 'emptyCircle', // 全部折线图
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
grid: { left: 72, right: 48, top: LAYOUT.gridTop, bottom: 62 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
valueFormatter: (v) => `${v}%`,
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
},
|
||||||
|
xAxis: { ...categoryAxis, data: periods },
|
||||||
|
yAxis: {
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '比率(%)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 46,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: 0,
|
||||||
|
max: 80,
|
||||||
|
interval: 25,
|
||||||
|
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
lineSeries({
|
||||||
|
name: '毛利率',
|
||||||
|
data: col(rows, 'gross_margin'),
|
||||||
|
color: PALETTE.green,
|
||||||
|
labelPosition: 'top',
|
||||||
|
formatter: pct,
|
||||||
|
}),
|
||||||
|
lineSeries({
|
||||||
|
name: '销售费用率',
|
||||||
|
data: col(rows, 'sales_expense_rate'),
|
||||||
|
color: PALETTE.orange,
|
||||||
|
labelPosition: 'top',
|
||||||
|
formatter: pct,
|
||||||
|
}),
|
||||||
|
lineSeries({
|
||||||
|
name: '管理费用率',
|
||||||
|
data: col(rows, 'admin_expense_rate'),
|
||||||
|
color: PALETTE.purple,
|
||||||
|
labelPosition: 'bottom',
|
||||||
|
formatter: pct,
|
||||||
|
}),
|
||||||
|
lineSeries({
|
||||||
|
name: '非国际归母净利率',
|
||||||
|
data: col(rows, 'non_gaap_net_margin'),
|
||||||
|
color: PALETTE.coral,
|
||||||
|
labelPosition: 'top',
|
||||||
|
formatter: pct,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote('示例数据 | @思考的Joey'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { col } from '../csv.js';
|
||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||||
|
lineSeries, footnote, watermarks, LAYOUT,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 泡泡玛特资产结构演变图
|
||||||
|
* 堆叠柱状图展示资产配置,折线图展示总资产增长
|
||||||
|
*/
|
||||||
|
export function assetsChart(rows, { width, height }) {
|
||||||
|
const periods = col(rows, 'period');
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: '泡泡玛特资产结构演变(2020H1-2026H1)',
|
||||||
|
subtext: '单位:亿元',
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
...legendStyle,
|
||||||
|
top: LAYOUT.legendTop,
|
||||||
|
data: ['类现金', '应收账款', '投资资产', '经营类资产', '资产总计'].map(name => ({
|
||||||
|
name,
|
||||||
|
icon: name === '资产总计' ? 'emptyCircle' : 'rect', // 总计是折线,其他是柱
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
},
|
||||||
|
xAxis: { ...periodAxis, data: periods },
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '金额(亿元)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 52,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: 0,
|
||||||
|
max: 350,
|
||||||
|
interval: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '资产总计(亿元)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 58,
|
||||||
|
nameRotate: -90,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: 0,
|
||||||
|
max: 350,
|
||||||
|
interval: 50,
|
||||||
|
splitLine: { show: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '类现金',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'assets',
|
||||||
|
data: col(rows, 'cash_like'),
|
||||||
|
itemStyle: { color: PALETTE.blue },
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '应收账款',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'assets',
|
||||||
|
data: col(rows, 'receivables'),
|
||||||
|
itemStyle: { color: PALETTE.orange },
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '投资资产',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'assets',
|
||||||
|
data: col(rows, 'investment'),
|
||||||
|
itemStyle: { color: PALETTE.purple },
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '经营类资产',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'assets',
|
||||||
|
data: col(rows, 'operating_assets'),
|
||||||
|
itemStyle: { color: PALETTE.sage },
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...lineSeries({
|
||||||
|
name: '资产总计',
|
||||||
|
data: col(rows, 'total_assets'),
|
||||||
|
color: PALETTE.red,
|
||||||
|
labelPosition: 'top',
|
||||||
|
formatter: (p) => p.value.toFixed(1),
|
||||||
|
width: 3,
|
||||||
|
}),
|
||||||
|
yAxisIndex: 1,
|
||||||
|
z: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote('数据来源:泡泡玛特财报 | @思考的Joey'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { col } from '../csv.js';
|
||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, titleStyle, footnote, watermarks,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 净利率同比拆解瀑布图(2025H1 → 2026H1)
|
||||||
|
*
|
||||||
|
* ECharts 没有原生瀑布图,用「透明占位柱 + 可见柱」堆叠模拟:
|
||||||
|
* 占位柱撑到该项起点,可见柱只画增量部分。
|
||||||
|
*
|
||||||
|
* 口径要点:各项先算占营收比的税前变化,再乘税盾 (1-t₁) 折成对净利率的贡献,
|
||||||
|
* 税率变化单独成项。恒等式 净利率 = 税前利润率 × (1-税率),
|
||||||
|
* Δ净 = Δ税前×(1-t₁) − 税前₂×Δt,实测闭合误差 0.00pct。
|
||||||
|
*/
|
||||||
|
const COLOR = {
|
||||||
|
total: '#2F5C85', // 首尾锚点
|
||||||
|
neg: '#C0392B', // 拖累
|
||||||
|
pos: '#1A9E5C', // 利好
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ppmtBridgeChart(rows, { width, height }) {
|
||||||
|
const items = col(rows, 'item');
|
||||||
|
const types = col(rows, 'type');
|
||||||
|
const bases = col(rows, 'base');
|
||||||
|
const values = col(rows, 'value');
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: '净利率同比拆解:33.7% → 29.7%(2025H1 → 2026H1)',
|
||||||
|
subtext: '单位:占营收百分点。汇兑等其他收益项是最大拖累,费用率改善仅部分对冲',
|
||||||
|
},
|
||||||
|
grid: { left: 114, right: 83, top: 120, bottom: 130 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
formatter: (ps) => {
|
||||||
|
const p = ps.find((x) => x.seriesName === '变化');
|
||||||
|
if (!p) return '';
|
||||||
|
const i = p.dataIndex;
|
||||||
|
if (types[i] === 'total') return `<b>${items[i]}</b><br/>${values[i].toFixed(2)}%`;
|
||||||
|
const sign = types[i] === 'pos' ? '+' : '−';
|
||||||
|
return `<b>${items[i]}</b><br/>${sign}${values[i].toFixed(2)} pct`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: items,
|
||||||
|
axisLine: { show: true, lineStyle: { color: PALETTE.axisLine } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
color: PALETTE.ink,
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: FONT,
|
||||||
|
interval: 0,
|
||||||
|
rotate: 32,
|
||||||
|
margin: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '净利率(%)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 52,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: 26,
|
||||||
|
max: 36,
|
||||||
|
interval: 2,
|
||||||
|
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
// 占位柱:撑到每项起点,完全透明
|
||||||
|
name: '占位',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'bridge',
|
||||||
|
silent: true,
|
||||||
|
itemStyle: { color: 'transparent' },
|
||||||
|
emphasis: { itemStyle: { color: 'transparent' } },
|
||||||
|
data: bases,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '变化',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'bridge',
|
||||||
|
// 柱宽固定时 barCategoryGap 无效,间距只能靠 grid 左右边距调节:
|
||||||
|
// 步距 = 绘图宽 / 类目数,间隙 = 步距 − barWidth
|
||||||
|
barWidth: 51,
|
||||||
|
data: values.map((v, i) => ({
|
||||||
|
value: v,
|
||||||
|
itemStyle: { color: COLOR[types[i]] },
|
||||||
|
})),
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: PALETTE.ink,
|
||||||
|
formatter: (p) => {
|
||||||
|
const i = p.dataIndex;
|
||||||
|
if (types[i] === 'total') return `${values[i].toFixed(1)}%`;
|
||||||
|
// 所有项都显示(资产减值0.01、其他业务净额0.01也必须标)
|
||||||
|
const sign = types[i] === 'pos' ? '+' : '−';
|
||||||
|
return `${sign}${values[i].toFixed(2)}pct`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote(
|
||||||
|
'各项为占营收百分点,已折算为对净利率影响(×税盾系数)\n' +
|
||||||
|
'其他收益净额:26H1净损失6.89亿,主要为汇兑损失7.20亿(25H1为汇兑收益1.20亿) | @思考的Joey'
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { col } from '../csv.js';
|
||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||||
|
footnote, watermarks, LAYOUT,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 泡泡玛特品类收入结构图
|
||||||
|
* 堆叠柱状图展示各品类的收入占比和增长
|
||||||
|
*/
|
||||||
|
export function categoryChart(rows, { width, height }) {
|
||||||
|
// 过滤掉表头行("品类"那一行)
|
||||||
|
const dataRows = rows.filter(r => r.period && r.period !== '品类');
|
||||||
|
const periods = col(dataRows, 'period');
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: '泡泡玛特品类收入结构(2024H1-2025H2)',
|
||||||
|
subtext: '单位:亿元',
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
...legendStyle,
|
||||||
|
top: LAYOUT.legendTop,
|
||||||
|
data: ['毛绒', '手办', 'MEGA', '衍生品及其他'].map(name => ({
|
||||||
|
name,
|
||||||
|
icon: 'rect', // 全部是堆叠柱状图
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
grid: { left: 78, right: 56, top: LAYOUT.gridTop, bottom: 90 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
formatter: (params) => {
|
||||||
|
let result = `<b>${params[0].axisValue}</b><br/>`;
|
||||||
|
let total = 0;
|
||||||
|
params.forEach(p => {
|
||||||
|
total += p.value;
|
||||||
|
result += `${p.marker}${p.seriesName}: ${p.value.toFixed(2)} 亿元<br/>`;
|
||||||
|
});
|
||||||
|
result += `<b>合计: ${total.toFixed(2)} 亿元</b>`;
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xAxis: { ...periodAxis, data: periods },
|
||||||
|
yAxis: {
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '收入(亿元)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 52,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: 0,
|
||||||
|
max: 250,
|
||||||
|
interval: 50,
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '毛绒',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'category',
|
||||||
|
data: col(dataRows, 'plush'),
|
||||||
|
itemStyle: { color: PALETTE.coral },
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'inside',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT,
|
||||||
|
formatter: (p) => (p.value > 15 ? p.value.toFixed(1) : ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '手办',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'category',
|
||||||
|
data: col(dataRows, 'figure'),
|
||||||
|
itemStyle: { color: PALETTE.blue },
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'inside',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT,
|
||||||
|
formatter: (p) => (p.value > 15 ? p.value.toFixed(1) : ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'MEGA',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'category',
|
||||||
|
data: col(dataRows, 'mega'),
|
||||||
|
itemStyle: { color: PALETTE.purple },
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'inside',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT,
|
||||||
|
formatter: (p) => (p.value > 8 ? p.value.toFixed(1) : ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '衍生品及其他',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'category',
|
||||||
|
data: col(dataRows, 'other'),
|
||||||
|
itemStyle: { color: PALETTE.sage },
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'inside',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT,
|
||||||
|
formatter: (p) => (p.value > 8 ? p.value.toFixed(1) : ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote('数据来源:泡泡玛特财报 | @思考的Joey'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { col } from '../csv.js';
|
||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, periodAxis, titleStyle,
|
||||||
|
lineSeries, standardLegend, footnote, watermarks, LAYOUT,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核心利润率(2020H1-2026H1)
|
||||||
|
* 核心利润率 = 核心利润 ÷ 营业收入 = 毛利率 − 销售费用率 − 管理费用率
|
||||||
|
*
|
||||||
|
* 剔除了汇兑、投资收益、税等非主业扰动,是四条率线里最能反映
|
||||||
|
* 「卖货本身赚不赚钱」的一条。22全年触底 10.8%,25全年见顶 45.6%。
|
||||||
|
*
|
||||||
|
* 配色 wine(#7B2D42):同页已有 17 种线色,Lab ΔE 筛选后仅此一色 > 24。
|
||||||
|
*/
|
||||||
|
const LINE_COLOR = '#7B2D42';
|
||||||
|
|
||||||
|
export function ppmtCoreMarginChart(rows, { width, height }) {
|
||||||
|
const pct = (p) => `${p.value.toFixed(1)}%`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: '泡泡玛特核心利润率(20H1-26H1)',
|
||||||
|
subtext: '核心利润率 = 毛利率 − 销售费用率 − 管理费用率,剔除汇兑与投资等非主业扰动',
|
||||||
|
},
|
||||||
|
legend: standardLegend(['核心利润率'], { itemGap: 32 }),
|
||||||
|
grid: { left: 78, right: 60, top: LAYOUT.gridTop, bottom: 90 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
valueFormatter: (v) => `${v.toFixed(1)}%`,
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
},
|
||||||
|
xAxis: { ...periodAxis, data: col(rows, 'period') },
|
||||||
|
yAxis: {
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '核心利润率(%)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 52,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: 0,
|
||||||
|
max: 50,
|
||||||
|
interval: 10,
|
||||||
|
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
lineSeries({
|
||||||
|
name: '核心利润率',
|
||||||
|
data: col(rows, 'core_margin'),
|
||||||
|
color: LINE_COLOR,
|
||||||
|
formatter: pct,
|
||||||
|
width: 3.08, // 与拆解图毛利率线一致
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote(
|
||||||
|
'核心利润率由绝对值口径直接计算(核心利润÷营收),比三个比率行相减更准\n' +
|
||||||
|
'——后者各只存4位小数,累加误差可达0.045pct | @思考的Joey'
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { col } from '../csv.js';
|
||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||||
|
footnote, watermarks, LAYOUT,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核心利润及同比增速(2020H1-2026H1)
|
||||||
|
* 核心利润 = 毛利 - 销售费用 - 管理费用,反映主业盈利能力去除营销和管理成本后的净额。
|
||||||
|
* 柱状图展示绝对值(亿元),折线图展示同比增速(%)。
|
||||||
|
*/
|
||||||
|
export function ppmtCoreProfitChart(rows, { width, height }) {
|
||||||
|
const periods = col(rows, 'period');
|
||||||
|
const core = col(rows, 'core_profit');
|
||||||
|
const yoy = col(rows, 'yoy');
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: '泡泡玛特核心利润及同比增速(20H1-26H1)',
|
||||||
|
subtext: '核心利润 = 毛利 - 销售费用 - 管理费用,单位:亿元',
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
top: LAYOUT.legendTop,
|
||||||
|
itemGap: 32,
|
||||||
|
textStyle: {
|
||||||
|
color: PALETTE.ink,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: FONT,
|
||||||
|
},
|
||||||
|
data: ['核心利润', '同比增速'],
|
||||||
|
},
|
||||||
|
grid: { left: 78, right: 128, top: LAYOUT.gridTop, bottom: 100 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'cross' },
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
formatter: (params) => {
|
||||||
|
let result = `<b>${params[0].axisValue}</b><br/>`;
|
||||||
|
params.forEach((p) => {
|
||||||
|
if (p.seriesName === '核心利润') {
|
||||||
|
result += `${p.marker}${p.seriesName}: ${p.value.toFixed(1)}亿<br/>`;
|
||||||
|
} else if (p.seriesName === '同比增速' && p.value !== null) {
|
||||||
|
result += `${p.marker}${p.seriesName}: ${p.value > 0 ? '+' : ''}${p.value.toFixed(1)}%<br/>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xAxis: { ...periodAxis, data: periods },
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
// 左轴:核心利润(亿元)
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '核心利润(亿元)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 48,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: 0,
|
||||||
|
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 右轴:同比增速(%)
|
||||||
|
type: 'value',
|
||||||
|
name: '同比增速(%)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 58,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: -100,
|
||||||
|
max: 500,
|
||||||
|
interval: 100,
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
color: PALETTE.grey,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT,
|
||||||
|
formatter: '{value}%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '核心利润',
|
||||||
|
type: 'bar',
|
||||||
|
yAxisIndex: 0,
|
||||||
|
data: core,
|
||||||
|
barWidth: 32,
|
||||||
|
itemStyle: { color: PALETTE.navy },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontSize: 11,
|
||||||
|
color: PALETTE.ink,
|
||||||
|
formatter: (p) => p.value.toFixed(1),
|
||||||
|
// 强制显示所有标签,即使柱子很矮
|
||||||
|
overflow: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '同比增速',
|
||||||
|
type: 'line',
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: yoy,
|
||||||
|
lineStyle: { color: PALETTE.coral, width: 3.08 }, // 与拆解图毛利率线一致
|
||||||
|
// 空心圆点:白心 + 彩色描边。图例会自动继承这个样式,
|
||||||
|
// 渲染成「短线 + 空心圆」
|
||||||
|
itemStyle: {
|
||||||
|
color: '#fff',
|
||||||
|
borderColor: PALETTE.coral,
|
||||||
|
borderWidth: 2.5, // 与 theme.js lineSeries 一致
|
||||||
|
},
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 9, // 与 theme.js lineSeries 一致
|
||||||
|
showSymbol: true,
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontSize: 11,
|
||||||
|
color: PALETTE.coral,
|
||||||
|
fontWeight: '600',
|
||||||
|
formatter: (p) => {
|
||||||
|
if (p.value === null || p.value === '') return '';
|
||||||
|
return `${p.value > 0 ? '+' : ''}${p.value.toFixed(1)}%`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote(
|
||||||
|
'核心利润反映主业盈利能力(扣除营销与管理成本);' +
|
||||||
|
'2025H1/H2同比增速受益于费用率优化和规模效应 | @思考的Joey'
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { col } from '../csv.js';
|
||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||||
|
lineSeries, footnote, watermarks, LAYOUT,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 泡泡玛特营收到利润的增速对比(2020H1-2026H1)
|
||||||
|
* 四条折线展示从成本到净利的增速分化。
|
||||||
|
* 净利为主角走实线,其余三条虚线退为参照。
|
||||||
|
*/
|
||||||
|
const SERIES = [
|
||||||
|
{ key: 'non_gaap_net_profit', name: '非GAAP归母净利', color: '#8B4789', width: 2.6, z: 40, dashed: false },
|
||||||
|
{ key: 'gross_profit', name: '毛利润', color: '#D9822B', width: 1.8, z: 30, dashed: true },
|
||||||
|
{ key: 'revenue', name: '营业收入', color: '#2F5C85', width: 1.8, z: 20, dashed: true },
|
||||||
|
{ key: 'cost', name: '营业成本', color: '#5B8C5A', width: 1.8, z: 10, dashed: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逐点决定标签方向:同一期内按数值排名交替 top/bottom,
|
||||||
|
* 相邻名次朝反方向散开。静态方向在 2020H1(四值挤在 16~61%)必然重叠,
|
||||||
|
* 而 label.position 不接受函数,只能按点覆盖 data。
|
||||||
|
*/
|
||||||
|
function labeledData(rows, key) {
|
||||||
|
return rows.map((row, i) => {
|
||||||
|
const value = row[key];
|
||||||
|
if (value === '' || value == null) return null;
|
||||||
|
|
||||||
|
// 该期所有系列的值,降序排名
|
||||||
|
const ranked = SERIES
|
||||||
|
.map((s) => row[s.key])
|
||||||
|
.filter((v) => v !== '' && v != null)
|
||||||
|
.sort((a, b) => b - a);
|
||||||
|
const rank = ranked.indexOf(value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
label: {
|
||||||
|
position: rank % 2 === 0 ? 'top' : 'bottom',
|
||||||
|
distance: 11 + Math.floor(rank / 2) * 13,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ppmtGrowthChart(rows, { width, height }) {
|
||||||
|
const pct = (p) => `${p.value}%`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: '泡泡玛特营收到利润的增速对比(2020H1-2026H1)',
|
||||||
|
subtext: '同比增速(%),净利增速显著高于营收增速体现利润弹性',
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
// 不继承 legendStyle,让 ECharts 按系列自动生成「短线+空心圆」图例
|
||||||
|
top: LAYOUT.legendTop,
|
||||||
|
itemGap: 34,
|
||||||
|
textStyle: {
|
||||||
|
color: PALETTE.ink,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: FONT,
|
||||||
|
},
|
||||||
|
data: ['非GAAP归母净利', '毛利润', '营业收入', '营业成本'],
|
||||||
|
},
|
||||||
|
grid: { left: 84, right: 56, top: LAYOUT.gridTop, bottom: 80 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
valueFormatter: (v) => `${v}%`,
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
},
|
||||||
|
xAxis: { ...periodAxis, data: col(rows, 'period') },
|
||||||
|
yAxis: {
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '同比增速(%)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 58,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: -100,
|
||||||
|
max: 400,
|
||||||
|
interval: 100,
|
||||||
|
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
||||||
|
},
|
||||||
|
series: SERIES.map((s) => ({
|
||||||
|
...lineSeries({
|
||||||
|
name: s.name,
|
||||||
|
data: labeledData(rows, s.key),
|
||||||
|
color: s.color,
|
||||||
|
formatter: pct,
|
||||||
|
width: s.width,
|
||||||
|
}),
|
||||||
|
// 整体替换 lineStyle,必须带上 color,否则会退回主题默认色
|
||||||
|
lineStyle: {
|
||||||
|
color: s.color,
|
||||||
|
type: s.dashed ? 'dashed' : 'solid',
|
||||||
|
width: s.width,
|
||||||
|
},
|
||||||
|
// 四条线数值常贴到一起。白描边保证交叠处可读,
|
||||||
|
// labelLayout.hideOverlap 让 ECharts 自动丢弃仍然碰撞的标签,
|
||||||
|
// 按 z 决定谁被保留 —— 净利最高,成本最先被舍弃。
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
color: s.color,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: FONT,
|
||||||
|
formatter: pct,
|
||||||
|
textBorderColor: '#fff',
|
||||||
|
textBorderWidth: 3,
|
||||||
|
},
|
||||||
|
labelLayout: { hideOverlap: true },
|
||||||
|
z: s.z,
|
||||||
|
})),
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote('数据来源:泡泡玛特财报,2020H1/H2非GAAP净利用归母净利替代 | @思考的Joey'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { col } from '../csv.js';
|
||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||||
|
lineSeries, footnote, watermarks, LAYOUT,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 泡泡玛特主要IP收入趋势图
|
||||||
|
* 多条折线展示各IP的收入变化
|
||||||
|
*/
|
||||||
|
export function ipChart(rows, { width, height }) {
|
||||||
|
// 提取时期(从第一行的列名)
|
||||||
|
const periods = Object.keys(rows[0]).filter(k => k !== 'ip');
|
||||||
|
|
||||||
|
// 为每个IP创建一条折线
|
||||||
|
const ipColors = {
|
||||||
|
'THE MONSTERS': PALETTE.brown,
|
||||||
|
'MOLLY': PALETTE.coral,
|
||||||
|
'SKULLPANDA': PALETTE.purple,
|
||||||
|
'CRYBABY': PALETTE.red,
|
||||||
|
'DIMOO': PALETTE.blue,
|
||||||
|
'HIRONO-小野': PALETTE.orange,
|
||||||
|
'HACIPUPU': PALETTE.sage,
|
||||||
|
'星星人': PALETTE.navy,
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = rows.map((row) => {
|
||||||
|
const ipName = row.ip;
|
||||||
|
const data = periods.map(p => row[p]);
|
||||||
|
|
||||||
|
return lineSeries({
|
||||||
|
name: ipName,
|
||||||
|
data,
|
||||||
|
color: ipColors[ipName] || PALETTE.grey,
|
||||||
|
labelPosition: 'top',
|
||||||
|
formatter: (p) => (p.value > 10 ? p.value.toFixed(0) : ''),
|
||||||
|
width: 2.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: '泡泡玛特主要IP收入趋势(2021H1-2025H2)',
|
||||||
|
subtext: '单位:亿元',
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
...legendStyle,
|
||||||
|
top: LAYOUT.legendTop,
|
||||||
|
itemGap: 20,
|
||||||
|
data: rows.map(r => ({
|
||||||
|
name: r.ip,
|
||||||
|
icon: 'emptyCircle', // 折线图:空心圆圈
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
grid: { left: 78, right: 56, top: LAYOUT.gridTop, bottom: 70 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
valueFormatter: (v) => `${v.toFixed(2)} 亿元`,
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
},
|
||||||
|
xAxis: { ...periodAxis, data: periods },
|
||||||
|
yAxis: {
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '收入(亿元)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 52,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
interval: 20,
|
||||||
|
},
|
||||||
|
series,
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote('数据来源:泡泡玛特财报 | @思考的Joey'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { col } from '../csv.js';
|
||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, labelStyle,
|
||||||
|
barSeries, lineSeries, standardLegend, footnote, watermarks, LAYOUT,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 泡泡玛特除 THE MONSTERS 外的 IP 收入及同比(2021H1-2026H1)
|
||||||
|
* 柱 = 各期 IP 收入合计 − THE MONSTERS,折线 = 同比增速。
|
||||||
|
* 看的是「剔掉唯一爆款后,其余 IP 盘子还在不在长」。
|
||||||
|
*
|
||||||
|
* 源:ppmt.xlsx「IP收入和占比」收入区的「计算合计」行减去 THE MONSTERS 行。
|
||||||
|
*
|
||||||
|
* ⚠ 这张表的 H2 是【单半年】,不是全年累计(已核对 2025H2 的 232.44
|
||||||
|
* = 利润表 371.2 − 138.76)。两个后果:
|
||||||
|
* 1. 轴标签只去世纪前缀(25H2),不能用 `periodAxis`(它会渲染成「25全年」)
|
||||||
|
* 2. 同比是「本期 vs 去年同半年」,即隔两列相除;前两期无基数,留空
|
||||||
|
*
|
||||||
|
* CSV 收入存两位小数:同比对基数的舍入敏感,一位小数在 16~20 亿的基数上
|
||||||
|
* 会让同比偏掉约 0.5pct。实测两位小数复算同比与全精度差 ≤ 0.02pct。
|
||||||
|
*
|
||||||
|
* 配色:墨翡翠柱 + 鲜亮紫线,两色 ΔE 隔着老远。
|
||||||
|
* 翡翠选得比紧邻堆叠图里 DIMOO 那支(#3E9E6E)更深,ΔE 22.7,不会被读成同一个色;
|
||||||
|
* 同时深到白字对比度 6.44:1,够柱内白色标签用。
|
||||||
|
*
|
||||||
|
* 标签避让(不加底色,白色药丸底太丑):
|
||||||
|
* 1. 柱子的数值放柱内白字(insideTop)——腾开柱顶上方,不和同比标签抢位置
|
||||||
|
* 2. 同比标签逐点判断:算出标签底边和柱顶的相对位置,只有整体压在柱面上的
|
||||||
|
* 那个点(26H1,线在 45%、柱顶在 80%)改用浅紫字,其余保持线色
|
||||||
|
*
|
||||||
|
* 浅紫而非白色:柱子自己的数值已经是白字,同比再用白字两者会混。
|
||||||
|
*/
|
||||||
|
const BAR_COLOR = '#176B52'; // 墨翡翠
|
||||||
|
const LINE_COLOR = '#7A3FE0'; // 鲜亮紫
|
||||||
|
const LINE_ON_BAR = '#DCC9FF'; // 压在翡翠柱面上时用的浅紫(对比度约 6:1)
|
||||||
|
|
||||||
|
const Y_MAX = 160; // 左轴上限
|
||||||
|
const YOY_MIN = -50; // 右轴下限
|
||||||
|
const YOY_MAX = 150; // 右轴上限
|
||||||
|
|
||||||
|
export function ppmtIpExMonstersChart(rows, { width, height }) {
|
||||||
|
const periods = col(rows, 'period');
|
||||||
|
const revenue = col(rows, 'revenue');
|
||||||
|
// 前两期无同比基数,CSV 里留空;parseCSV 会给出 '',统一转成 null
|
||||||
|
const yoy = col(rows, 'yoy').map((v) => (v === '' ? null : v));
|
||||||
|
|
||||||
|
// 逐点判断同比标签会不会压在柱面上。`label.position` 不接受函数(见 CLAUDE.md),
|
||||||
|
// 所以只能把 data 写成对象、逐点覆盖 color。
|
||||||
|
const plotH = height - LAYOUT.gridTop - 90; // 与下面 grid 的 top/bottom 一致
|
||||||
|
const labelGap = (8 + 13) / plotH; // labelStyle 的 distance + 字高,换成占比
|
||||||
|
const yoyData = yoy.map((v, i) => {
|
||||||
|
if (v === null) return null;
|
||||||
|
const barFrac = revenue[i] / Y_MAX;
|
||||||
|
const lineFrac = (v - YOY_MIN) / (YOY_MAX - YOY_MIN);
|
||||||
|
// 标签整体(底边到顶边)都落在柱子高度以内 → 压在柱面上
|
||||||
|
const onBar = lineFrac + labelGap < barFrac;
|
||||||
|
return onBar ? { value: v, label: { color: LINE_ON_BAR } } : v;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: '泡泡玛特除 THE MONSTERS 外的IP收入及同比(21H1-26H1)',
|
||||||
|
subtext: 'IP收入合计 − THE MONSTERS,单位:亿元;H2 为下半年单期,非全年累计',
|
||||||
|
},
|
||||||
|
legend: standardLegend(['除MONSTERS外收入', '同比增速'], { itemGap: 32 }),
|
||||||
|
grid: { left: 78, right: 128, top: LAYOUT.gridTop, bottom: 90 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'cross' },
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
formatter: (ps) => {
|
||||||
|
let out = `<b>${ps[0].axisValue}</b><br/>`;
|
||||||
|
ps.forEach((p) => {
|
||||||
|
if (p.seriesName === '除MONSTERS外收入') {
|
||||||
|
out += `${p.marker}${p.seriesName}: ${p.value.toFixed(1)} 亿元<br/>`;
|
||||||
|
} else if (p.value !== null && p.value !== '') {
|
||||||
|
out += `${p.marker}${p.seriesName}: ${p.value > 0 ? '+' : ''}${p.value.toFixed(1)}%<br/>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
...categoryAxis,
|
||||||
|
data: periods,
|
||||||
|
// 只去世纪前缀:这张表的 H2 是单半年,不能简写成「全年」
|
||||||
|
axisLabel: { ...categoryAxis.axisLabel, formatter: (v) => v.replace(/^20/, '') },
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '收入(亿元)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 50,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: 0,
|
||||||
|
// 定死 160:留出顶部余量,最高柱(139.0)不会顶到绘图区上沿
|
||||||
|
max: Y_MAX,
|
||||||
|
interval: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '同比增速(%)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 60,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: YOY_MIN,
|
||||||
|
max: YOY_MAX,
|
||||||
|
interval: 50,
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLabel: { color: PALETTE.grey, fontSize: 12, fontFamily: FONT, formatter: '{value}%' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
barSeries({
|
||||||
|
name: '除MONSTERS外收入',
|
||||||
|
data: revenue,
|
||||||
|
color: BAR_COLOR,
|
||||||
|
barWidth: 34,
|
||||||
|
yAxisIndex: 0,
|
||||||
|
// 数值放柱内白字:腾开柱顶上方,避免和同比线的标签抢位置
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'insideTop',
|
||||||
|
color: '#fff',
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
formatter: (p) => p.value.toFixed(1),
|
||||||
|
overflow: 'none', // 矮柱也要显示,见 CLAUDE.md
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
// lineSeries() 没有 ...rest,yAxisIndex 只能展开后再补
|
||||||
|
...lineSeries({ name: '同比增速', data: yoyData, color: LINE_COLOR }),
|
||||||
|
yAxisIndex: 1,
|
||||||
|
label: {
|
||||||
|
...labelStyle(LINE_COLOR, 'top'),
|
||||||
|
formatter: (p) => (p.value === null || p.value === '' ? ''
|
||||||
|
: `${p.value > 0 ? '+' : ''}${p.value.toFixed(1)}%`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote('除 THE MONSTERS 外其余 IP 合计;同比对去年同半年 | @思考的Joey'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import {
|
||||||
|
PALETTE, FONT, titleStyle, footnote, watermarks,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 泡泡玛特主要 IP 收入占比(2026H1)
|
||||||
|
* 饼图(非环图),图例放在右侧:每项上行 IP 名、下行小字显示收入和占比。
|
||||||
|
* 扇区外直接标注 IP 名 + 数值(两行)。源:ppmt.xlsx「IP收入和占比」2026H1 列,
|
||||||
|
* HACIPUPU、BUNNY、PUCKY 等该期无数据的 IP 不列入。
|
||||||
|
* 占比为衍生指标:各 IP 收入 ÷ 全部列入 IP 收入合计(源表占比列未填)。
|
||||||
|
*
|
||||||
|
* 配色:手写的一条色相渐变谱(红→橙→金→橄榄→翡翠→青→蓝→紫),
|
||||||
|
* 末位「外采及其他」用中性灰(残差项惯例)。冷色段靠明暗交替拉开
|
||||||
|
* (深蓝 → 浅蓝紫 → 紫 → 浅兰紫),避免同色系挤在一起分不清。
|
||||||
|
* 相邻扇区及全组两两 ΔE ≥ 24.6,与同页 ppmt_margins 四色及 wine ΔE ≥ 25。
|
||||||
|
*/
|
||||||
|
const COLORS = [
|
||||||
|
'#D64545', // THE MONSTERS 玫红
|
||||||
|
'#E8913C', // 星星人 琥珀橙
|
||||||
|
'#D9C043', // 授权IP(非独家) 金
|
||||||
|
'#7FA83C', // CRYBABY 橄榄绿
|
||||||
|
'#3E9E6E', // DIMOO 翡翠
|
||||||
|
'#3FB6D0', // SKULLPANDA 亮青
|
||||||
|
'#2E5FA8', // 其他艺术家IP 深蓝
|
||||||
|
'#8E86E0', // HIRONO-小野 浅蓝紫
|
||||||
|
'#8A3FA8', // MOLLY 紫
|
||||||
|
'#D081C4', // Zsiga 浅兰紫
|
||||||
|
'#8C8C8C', // 外采及其他 中性灰
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ppmtIpShareChart(rows, { width, height }) {
|
||||||
|
const data = rows.map((row, i) => ({
|
||||||
|
name: row.ip,
|
||||||
|
value: row.revenue,
|
||||||
|
itemStyle: { color: COLORS[i % COLORS.length] },
|
||||||
|
}));
|
||||||
|
const total = data.reduce((s, d) => s + d.value, 0);
|
||||||
|
const byName = Object.fromEntries(data.map((d) => [d.name, d]));
|
||||||
|
|
||||||
|
// 把「饼图 + 两侧外标签」当成一个整体,与图例并成一组居中摆放:
|
||||||
|
// 图例紧跟在右侧标签外缘之后,不会被推到画布边缘留下大片空白
|
||||||
|
const LEGEND_WIDTH = 152; // 最宽一行「17.7 亿元 · 10.3%」约 120px + 色块
|
||||||
|
const LEGEND_GAP = 24; // 右侧标签外缘 → 图例
|
||||||
|
// 左右标签占位不对称:左侧文字向左延伸(text-anchor: end)要占满,
|
||||||
|
// 右侧只需容下引导线 + 首行文字起段,留太多会在图例前空出一大块
|
||||||
|
const LABEL_LEFT = 135;
|
||||||
|
const LABEL_RIGHT = 72;
|
||||||
|
const topPad = 78;
|
||||||
|
const bottomPad = 64;
|
||||||
|
const availH = height - topPad - bottomPad;
|
||||||
|
|
||||||
|
// 半径取高/宽两个方向的较小值;上下各留 35px 给最上、最下扇区的两行标签
|
||||||
|
// 末尾 ×0.85:实测 250 偏大,缩 15% 留出呼吸感
|
||||||
|
const rByHeight = availH / 2 - 35;
|
||||||
|
const rByWidth = (width - 48 - LEGEND_GAP - LEGEND_WIDTH - LABEL_LEFT - LABEL_RIGHT) / 2;
|
||||||
|
const radius = Math.max(80, Math.min(rByHeight, rByWidth, 250) * 0.85);
|
||||||
|
|
||||||
|
const blockW = radius * 2 + LABEL_LEFT + LABEL_RIGHT;
|
||||||
|
const leftMargin = Math.max(16, (width - blockW - LEGEND_GAP - LEGEND_WIDTH) / 2);
|
||||||
|
const centerX = leftMargin + LABEL_LEFT + radius;
|
||||||
|
const centerY = topPad + availH / 2;
|
||||||
|
const legendLeft = leftMargin + blockW + LEGEND_GAP;
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: '泡泡玛特主要IP收入占比(2026H1)',
|
||||||
|
subtext: `占比 = 各IP收入 ÷ 全部IP收入合计(合计 ${total.toFixed(1)} 亿元),单位:亿元`,
|
||||||
|
},
|
||||||
|
// 图例在右侧承载 IP 名 + 收入/占比明细
|
||||||
|
legend: {
|
||||||
|
orient: 'vertical',
|
||||||
|
left: legendLeft,
|
||||||
|
width: LEGEND_WIDTH,
|
||||||
|
top: 'middle',
|
||||||
|
itemGap: 14,
|
||||||
|
itemWidth: 14,
|
||||||
|
itemHeight: 14,
|
||||||
|
align: 'left',
|
||||||
|
data: data.map((d) => d.name),
|
||||||
|
formatter: (name) => {
|
||||||
|
const d = byName[name];
|
||||||
|
const pct = (d.value / total) * 100;
|
||||||
|
return `{name|${name}}\n{sub|${d.value.toFixed(1)} 亿元 · ${pct.toFixed(1)}%}`;
|
||||||
|
},
|
||||||
|
textStyle: {
|
||||||
|
rich: {
|
||||||
|
name: { fontFamily: FONT, fontSize: 13, fontWeight: 600, color: PALETTE.ink, lineHeight: 18 },
|
||||||
|
sub: { fontFamily: FONT, fontSize: 11, color: PALETTE.grey, lineHeight: 16 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
formatter: (p) => `${p.marker}${p.name}:${p.value.toFixed(1)} 亿元(${p.percent.toFixed(1)}%)`,
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'IP收入占比',
|
||||||
|
type: 'pie',
|
||||||
|
radius,
|
||||||
|
center: [centerX, centerY],
|
||||||
|
data,
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
formatter: (p) => `{name|${p.name}}\n{value|${p.value.toFixed(1)} 亿元}`,
|
||||||
|
rich: {
|
||||||
|
name: { fontFamily: FONT, fontSize: 12, color: PALETTE.ink, fontWeight: 600, lineHeight: 16 },
|
||||||
|
value: { fontFamily: FONT, fontSize: 11, color: PALETTE.grey, lineHeight: 15 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
labelLine: { length: 14, length2: 16, lineStyle: { color: PALETTE.axisLine } },
|
||||||
|
itemStyle: { borderColor: '#fff', borderWidth: 2 },
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: { shadowBlur: 12, shadowColor: 'rgba(0,0,0,0.2)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote('HACIPUPU、BUNNY、PUCKY 等 IP 2026H1 无收入数据,未列入 | @思考的Joey'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, categoryAxis, titleStyle,
|
||||||
|
barSeries, footnote, watermarks, LAYOUT,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 泡泡玛特各 IP 半年收入占比(2021H1-2026H1)
|
||||||
|
* 归一化堆叠柱状图:每根柱固定 100%,看的是收入结构的变化而非规模。
|
||||||
|
* 源:ppmt.xlsx「IP收入和占比」的收入区;该表占比区未填,占比由收入算出。
|
||||||
|
*
|
||||||
|
* ⚠ 这张表的 H2 是【单半年】,不是全年累计——已核对 2025H2 的 232.44
|
||||||
|
* = 利润表 371.2 − 138.76。所以轴标签只去世纪前缀(25H2),
|
||||||
|
* 不能用 theme 的 `periodAxis`(它会渲染成「25全年」,对这张图是错的)。
|
||||||
|
*
|
||||||
|
* CSV 存两位小数:归一化占比对小值敏感,一位小数会让早期小 IP 失真
|
||||||
|
* (如 CRYBABY 2023H1 的 0.27 记成 0.3,相对误差 13%)。
|
||||||
|
*
|
||||||
|
* 18 行源数据里 8 个已停单列或体量极小的 IP 合并成「其他IP(含已停单列)」,
|
||||||
|
* 合并后仍是 11 个系列、逐期加总恰好 100%。
|
||||||
|
*
|
||||||
|
* 配色按 IP 名与同页饼图 ppmt_ip_share.js 一一对应,便于两图互相对照;
|
||||||
|
* 合并桶沿用 Zsiga 原色(Zsiga 已并入其中)。
|
||||||
|
*/
|
||||||
|
const COLORS = {
|
||||||
|
'THE MONSTERS': '#D64545',
|
||||||
|
'星星人': '#E8913C',
|
||||||
|
'授权IP(非独家)': '#D9C043',
|
||||||
|
'CRYBABY': '#7FA83C',
|
||||||
|
'DIMOO': '#3E9E6E',
|
||||||
|
'SKULLPANDA': '#3FB6D0',
|
||||||
|
'其他艺术家IP': '#2E5FA8',
|
||||||
|
'HIRONO-小野': '#8E86E0',
|
||||||
|
'MOLLY': '#8A3FA8',
|
||||||
|
'其他IP(含已停单列)': '#D081C4',
|
||||||
|
'外采及其他': '#8C8C8C',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEGEND_WIDTH = 160;
|
||||||
|
const LEGEND_RIGHT = 20;
|
||||||
|
|
||||||
|
export function ppmtIpShareHistoryChart(rows, { width, height }) {
|
||||||
|
const periods = Object.keys(rows[0]).filter((k) => k !== 'ip');
|
||||||
|
const names = rows.map((r) => r.ip);
|
||||||
|
const totals = periods.map((p) => rows.reduce((s, r) => s + r[p], 0));
|
||||||
|
// 归一化:各 IP 占当期合计的比重,逐期加总恰为 100%
|
||||||
|
const toPct = (r) => periods.map((p, i) => (r[p] / totals[i]) * 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: '泡泡玛特各IP半年收入占比(21H1-26H1)',
|
||||||
|
subtext: '占当期IP收入合计的比重;H2 为下半年单期,非全年累计',
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
orient: 'vertical',
|
||||||
|
right: LEGEND_RIGHT,
|
||||||
|
width: LEGEND_WIDTH,
|
||||||
|
top: 'middle',
|
||||||
|
itemGap: 12,
|
||||||
|
itemWidth: 14,
|
||||||
|
itemHeight: 14,
|
||||||
|
data: names,
|
||||||
|
textStyle: { color: PALETTE.ink, fontSize: 12, fontFamily: FONT },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
formatter: (ps) => {
|
||||||
|
const i = periods.indexOf(ps[0].axisValue);
|
||||||
|
const head = `<b>${ps[0].axisValue}</b> 合计 ${totals[i].toFixed(1)} 亿元<br/>`;
|
||||||
|
return head + ps
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.filter((p) => p.value > 0)
|
||||||
|
.map((p) => `${p.marker}${p.seriesName}: ${p.value.toFixed(1)}%`
|
||||||
|
+ `(${((p.value / 100) * totals[i]).toFixed(1)} 亿元)`)
|
||||||
|
.join('<br/>');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: 78,
|
||||||
|
right: LEGEND_RIGHT + LEGEND_WIDTH + 20,
|
||||||
|
top: LAYOUT.gridTop,
|
||||||
|
bottom: 70,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
...categoryAxis,
|
||||||
|
data: periods,
|
||||||
|
// 只去世纪前缀:这张表的 H2 是单半年,不能简写成「全年」
|
||||||
|
axisLabel: { ...categoryAxis.axisLabel, formatter: (v) => v.replace(/^20/, '') },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
interval: 20,
|
||||||
|
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
||||||
|
},
|
||||||
|
series: rows.map((r) => barSeries({
|
||||||
|
name: r.ip,
|
||||||
|
data: toPct(r),
|
||||||
|
color: COLORS[r.ip] ?? PALETTE.grey,
|
||||||
|
barWidth: 40,
|
||||||
|
stack: 'share',
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'inside',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontWeight: 500,
|
||||||
|
// 低于 4% 的段塞不进字,留空靠 tooltip 读数
|
||||||
|
formatter: (p) => (p.value >= 4 ? p.value.toFixed(1) : ''),
|
||||||
|
},
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
})),
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote('其他IP(含已停单列)= Zsiga、HACIPUPU、BUNNY、PUCKY、小甜豆等 | @思考的Joey'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { col } from '../csv.js';
|
||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||||
|
lineSeries, footnote, watermarks, LAYOUT,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 泡泡玛特利润率与费用率拆解(2020H1-2026H1)
|
||||||
|
* 毛利率独占上方区间,销售/管理费用率与非GAAP净利率在 5~35% 交织。
|
||||||
|
* H2 列为全年累计口径,H1 为半年口径(与利润表一致)。
|
||||||
|
*
|
||||||
|
* 配色刻意避开 demo margin.js 的 green/orange/purple/coral 和
|
||||||
|
* 同页 growth 的 plum/amber/steel/moss,用 Lab ΔE>25 保证明显不同。
|
||||||
|
*/
|
||||||
|
const SERIES = [
|
||||||
|
{ key: 'gross_margin', name: '毛利率', color: '#00696E', width: 3.08 }, // deep teal, 2.8 × 1.1
|
||||||
|
{ key: 'sales_expense_rate', name: '销售费用率', color: '#A8862B', width: 2.2 }, // mustard, 2.0 × 1.1
|
||||||
|
{ key: 'admin_expense_rate', name: '管理费用率', color: '#4A2D5C', width: 2.2 }, // aubergine, 2.0 × 1.1
|
||||||
|
{ key: 'non_gaap_net_margin', name: '非国际归母净利率', color: '#BE3E70', width: 2.86 }, // magenta rose, 2.6 × 1.1
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逐点决定标签朝向:放到"离最近邻居更远"的那一侧。
|
||||||
|
*
|
||||||
|
* 早先按排名奇偶交替(rank%2)会让排名相邻的两条线相向靠拢 ——
|
||||||
|
* 2024H1(销售29.69 / 净利22.33)和 2026H1(净利30.02 / 销售22.89)
|
||||||
|
* 两处标签间距只剩 10~11px < 字高 14px,于是被 hideOverlap 丢弃,
|
||||||
|
* 表现就是"2026H1 的非国际归母净利率看不到"。
|
||||||
|
*
|
||||||
|
* 现在比较上下间隙取大者:最高值必朝上、最低值必朝下,
|
||||||
|
* 中间值背对最挤的一侧。13 期实测 0 碰撞,无需再丢标签。
|
||||||
|
*
|
||||||
|
* 注:label.position 不接受函数,只能按数据点逐个覆盖。
|
||||||
|
*/
|
||||||
|
function labeledData(rows, key) {
|
||||||
|
return rows.map((row) => {
|
||||||
|
const value = row[key];
|
||||||
|
if (value === '' || value == null) return null;
|
||||||
|
|
||||||
|
// 该期所有系列值,降序
|
||||||
|
const ranked = SERIES
|
||||||
|
.map((s) => row[s.key])
|
||||||
|
.filter((v) => v !== '' && v != null)
|
||||||
|
.sort((a, b) => b - a);
|
||||||
|
const rank = ranked.indexOf(value);
|
||||||
|
|
||||||
|
const gapUp = rank > 0 ? ranked[rank - 1] - value : Infinity;
|
||||||
|
const gapDown = rank < ranked.length - 1 ? value - ranked[rank + 1] : Infinity;
|
||||||
|
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
label: { position: gapUp >= gapDown ? 'top' : 'bottom', distance: 10 },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ppmtMarginsChart(rows, { width, height }) {
|
||||||
|
const pct = (p) => `${p.value}%`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: '泡泡玛特利润率与费用率拆解(2020H1-2026H1)',
|
||||||
|
subtext: '毛利率抬升叠加费用率下降,共同推动净利率走高(H1为半年,H2为全年累计)',
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
// 不继承 legendStyle:它带 icon:'roundRect',会盖掉 ECharts 按系列
|
||||||
|
// 自动生成的「短线+空心圆」图例
|
||||||
|
top: LAYOUT.legendTop,
|
||||||
|
itemGap: 28,
|
||||||
|
textStyle: {
|
||||||
|
color: PALETTE.ink,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: FONT,
|
||||||
|
},
|
||||||
|
data: SERIES.map((s) => s.name),
|
||||||
|
},
|
||||||
|
grid: { left: 78, right: 56, top: LAYOUT.gridTop, bottom: 80 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
valueFormatter: (v) => `${v}%`,
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
},
|
||||||
|
xAxis: { ...periodAxis, data: col(rows, 'period') },
|
||||||
|
yAxis: {
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '比率(%)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 50,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: 0,
|
||||||
|
max: 80,
|
||||||
|
interval: 20,
|
||||||
|
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
||||||
|
},
|
||||||
|
series: SERIES.map((s) => ({
|
||||||
|
...lineSeries({
|
||||||
|
name: s.name,
|
||||||
|
data: labeledData(rows, s.key),
|
||||||
|
color: s.color,
|
||||||
|
formatter: pct,
|
||||||
|
width: s.width,
|
||||||
|
}),
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
color: s.color,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: FONT,
|
||||||
|
formatter: pct,
|
||||||
|
// 白描边保证交叉处压线的标签仍可读
|
||||||
|
textBorderColor: '#fff',
|
||||||
|
textBorderWidth: 3,
|
||||||
|
},
|
||||||
|
labelLayout: { hideOverlap: true },
|
||||||
|
})),
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote(
|
||||||
|
'毛利率/费用率取自利润表;非国际归母净利率=非GAAP净利÷营业收入,' +
|
||||||
|
'2020H1/H2非GAAP缺失用归母净利替代 | @思考的Joey'
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { col } from '../csv.js';
|
||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||||
|
lineSeries, footnote, watermarks, LAYOUT,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 泡泡玛特盈利能力分析图
|
||||||
|
* 柱状图展示收入和利润,折线图展示毛利率
|
||||||
|
*/
|
||||||
|
export function profitChart(rows, { width, height }) {
|
||||||
|
const periods = col(rows, 'period').reverse(); // 按时间正序
|
||||||
|
const revenue = col(rows, 'revenue').reverse();
|
||||||
|
const grossProfit = col(rows, 'gross_profit').reverse();
|
||||||
|
const netProfit = col(rows, 'net_profit').reverse();
|
||||||
|
const grossMargin = col(rows, 'gross_margin').reverse();
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: '泡泡玛特盈利能力分析(2022H2-2026H1)',
|
||||||
|
subtext: '营收、毛利、净利润及毛利率变化',
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
...legendStyle,
|
||||||
|
top: LAYOUT.legendTop,
|
||||||
|
data: [
|
||||||
|
{ name: '营业收入', icon: 'rect' },
|
||||||
|
{ name: '毛利润', icon: 'rect' },
|
||||||
|
{ name: '归母净利润', icon: 'rect' },
|
||||||
|
{ name: '毛利率', icon: 'emptyCircle' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
},
|
||||||
|
xAxis: { ...periodAxis, data: periods },
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '金额(亿元)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 52,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: 0,
|
||||||
|
max: 400,
|
||||||
|
interval: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '毛利率(%)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 58,
|
||||||
|
nameRotate: -90,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: 55,
|
||||||
|
max: 75,
|
||||||
|
interval: 5,
|
||||||
|
axisLabel: {
|
||||||
|
...axisCommon.axisLabel,
|
||||||
|
formatter: '{value}%',
|
||||||
|
},
|
||||||
|
splitLine: { show: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '营业收入',
|
||||||
|
type: 'bar',
|
||||||
|
data: revenue,
|
||||||
|
barWidth: 18,
|
||||||
|
itemStyle: { color: PALETTE.navy },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
color: PALETTE.ink,
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: FONT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '毛利润',
|
||||||
|
type: 'bar',
|
||||||
|
data: grossProfit,
|
||||||
|
barWidth: 18,
|
||||||
|
itemStyle: { color: PALETTE.sage },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
color: PALETTE.ink,
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: FONT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '归母净利润',
|
||||||
|
type: 'bar',
|
||||||
|
data: netProfit,
|
||||||
|
barWidth: 18,
|
||||||
|
itemStyle: { color: PALETTE.orange },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
color: PALETTE.ink,
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: FONT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...lineSeries({
|
||||||
|
name: '毛利率',
|
||||||
|
data: grossMargin,
|
||||||
|
color: PALETTE.red,
|
||||||
|
labelPosition: 'top',
|
||||||
|
formatter: (p) => `${p.value.toFixed(1)}%`,
|
||||||
|
width: 3,
|
||||||
|
}),
|
||||||
|
yAxisIndex: 1,
|
||||||
|
z: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote('数据来源:泡泡玛特财报(累计数据)| @思考的Joey'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
PALETTE, FONT, titleStyle, footnote, watermarks,
|
||||||
|
LAYOUT, } from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 泡泡玛特区域渠道分布图(2026H1)
|
||||||
|
* 使用交错正负轴展示不同区域和渠道的收入
|
||||||
|
*/
|
||||||
|
export function regionChannelChart(rows, { width, height }) {
|
||||||
|
// 数据结构重组:按区域分组
|
||||||
|
const regions = ['中国', '海外', '亚太', '美洲', '欧洲及其他'];
|
||||||
|
const channels = ['线下渠道', '线上渠道', '批发及其他'];
|
||||||
|
|
||||||
|
// 构建数据映射
|
||||||
|
const dataMap = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
const key = `${row.region}_${row.channel}`;
|
||||||
|
dataMap[key] = row.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 为每个渠道构建系列数据
|
||||||
|
const seriesData = channels.map(channel => {
|
||||||
|
return regions.map(region => {
|
||||||
|
const key = `${region}_${channel}`;
|
||||||
|
const value = dataMap[key] || 0;
|
||||||
|
// 线上渠道显示为负值(左侧),其他为正值(右侧)
|
||||||
|
return channel === '线上渠道' ? -value : value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 配色
|
||||||
|
const channelColors = {
|
||||||
|
'线下渠道': PALETTE.navy,
|
||||||
|
'线上渠道': PALETTE.coral,
|
||||||
|
'批发及其他': PALETTE.sage,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: '泡泡玛特区域渠道分布(2026H1)',
|
||||||
|
subtext: '左侧:线上渠道 | 右侧:线下渠道、批发及其他,单位:亿元',
|
||||||
|
top: 18,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: channels.map(name => ({ name, icon: 'rect' })), // 全部堆叠柱状图
|
||||||
|
top: LAYOUT.legendTop,
|
||||||
|
itemGap: 32,
|
||||||
|
textStyle: { fontFamily: FONT, fontSize: 13 },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
formatter: (params) => {
|
||||||
|
let result = `<b>${params[0].axisValue}</b><br/>`;
|
||||||
|
params.forEach(p => {
|
||||||
|
const value = Math.abs(p.value);
|
||||||
|
result += `${p.marker}${p.seriesName}: ${value.toFixed(2)} 亿元<br/>`;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: 100,
|
||||||
|
right: 100,
|
||||||
|
top: 145,
|
||||||
|
bottom: 90,
|
||||||
|
containLabel: false,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'value',
|
||||||
|
position: 'top',
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: { color: '#e8e8e8', width: 1 }
|
||||||
|
},
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (value) => Math.abs(value),
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#8c8c8c',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: regions,
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#333',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
inverse: false,
|
||||||
|
},
|
||||||
|
series: channels.map((channel, idx) => ({
|
||||||
|
name: channel,
|
||||||
|
type: 'bar',
|
||||||
|
stack: channel === '线上渠道' ? 'left' : 'right',
|
||||||
|
data: seriesData[idx],
|
||||||
|
itemStyle: { color: channelColors[channel] },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: channel === '线上渠道' ? 'left' : 'right',
|
||||||
|
formatter: (params) => {
|
||||||
|
const value = Math.abs(params.value);
|
||||||
|
return value > 0 ? value.toFixed(1) : '';
|
||||||
|
},
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
barWidth: 22,
|
||||||
|
})),
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote('数据来源:泡泡玛特财报 | @思考的Joey'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
PALETTE, FONT, titleStyle, footnote, watermarks,
|
||||||
|
LAYOUT, } from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 泡泡玛特区域渠道同比增速图(2026H1 vs 2025H1)
|
||||||
|
* 垂直条形图,每个渠道使用统一颜色
|
||||||
|
*/
|
||||||
|
export function regionChannelYoyChart(rows, { width, height }) {
|
||||||
|
// 按区域和渠道重组数据
|
||||||
|
const regions = ['中国', '海外', '亚太', '美洲', '欧洲及其他'];
|
||||||
|
const channels = ['线下渠道', '线上渠道', '批发及其他'];
|
||||||
|
|
||||||
|
// 构建数据映射
|
||||||
|
const dataMap = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
const key = `${row.region}_${row.channel}`;
|
||||||
|
dataMap[key] = row.yoy;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 为每个渠道构建数据系列
|
||||||
|
const seriesData = channels.map(channel => {
|
||||||
|
return regions.map(region => {
|
||||||
|
const key = `${region}_${channel}`;
|
||||||
|
return dataMap[key] || 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 为每个渠道定义统一的颜色
|
||||||
|
const channelColors = {
|
||||||
|
'线下渠道': PALETTE.navy,
|
||||||
|
'线上渠道': PALETTE.coral,
|
||||||
|
'批发及其他': PALETTE.sage,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: '泡泡玛特区域渠道同比增速(2026H1 vs 2025H1)',
|
||||||
|
subtext: '各区域渠道同比增速对比',
|
||||||
|
top: 18,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: channels.map(name => ({ name, icon: 'rect' })), // 全部柱状图
|
||||||
|
top: LAYOUT.legendTop,
|
||||||
|
itemGap: 32,
|
||||||
|
textStyle: { fontFamily: FONT, fontSize: 13 },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
formatter: (params) => {
|
||||||
|
let result = `<b>${params[0].axisValue}</b><br/>`;
|
||||||
|
params.forEach(p => {
|
||||||
|
const value = p.value;
|
||||||
|
const sign = value >= 0 ? '+' : '';
|
||||||
|
result += `${p.marker}${p.seriesName}: ${sign}${value.toFixed(1)}%<br/>`;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: 80,
|
||||||
|
right: 60,
|
||||||
|
top: 145,
|
||||||
|
bottom: 90,
|
||||||
|
containLabel: false,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: regions,
|
||||||
|
axisLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: { color: '#333', width: 2 }
|
||||||
|
},
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: { color: '#e8e8e8', width: 1 }
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
formatter: '{value}%',
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#8c8c8c',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: channels.map((channel, idx) => ({
|
||||||
|
name: channel,
|
||||||
|
type: 'bar',
|
||||||
|
data: seriesData[idx],
|
||||||
|
itemStyle: { color: channelColors[channel] },
|
||||||
|
barWidth: 47,
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'inside',
|
||||||
|
formatter: (params) => {
|
||||||
|
const value = params.value;
|
||||||
|
const sign = value >= 0 ? '+' : '';
|
||||||
|
return `${sign}${value.toFixed(1)}%`;
|
||||||
|
},
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote('数据来源:泡泡玛特财报(2026H1 vs 2025H1同比)| @思考的Joey'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import {
|
||||||
|
PALETTE, FONT, titleStyle, footnote, watermarks,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 泡泡玛特利润流向桑基图(2026H1)
|
||||||
|
* 从营业收入到非GAAP归母净利润的完整流程
|
||||||
|
*/
|
||||||
|
export function sankeyChart(rows, { width, height }) {
|
||||||
|
// 从 CSV 构建桑基图的 links 数据
|
||||||
|
const links = rows.map(row => ({
|
||||||
|
source: row.source,
|
||||||
|
target: row.target,
|
||||||
|
value: row.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 定义节点颜色
|
||||||
|
const nodeColors = {
|
||||||
|
'营业收入': PALETTE.navy,
|
||||||
|
'营业成本': PALETTE.grey,
|
||||||
|
'毛利': PALETTE.sage,
|
||||||
|
'销售费用': PALETTE.orange,
|
||||||
|
'管理费用': PALETTE.purple,
|
||||||
|
'财务费用': PALETTE.brown,
|
||||||
|
'营业利润': PALETTE.green,
|
||||||
|
'投资收益': PALETTE.blue,
|
||||||
|
'公允价值变动': PALETTE.blue,
|
||||||
|
'其他收入': PALETTE.blue,
|
||||||
|
'利润总额': PALETTE.sage,
|
||||||
|
'所得税费用': PALETTE.grey,
|
||||||
|
'净利润': PALETTE.green,
|
||||||
|
'少数股东损益': PALETTE.grey,
|
||||||
|
'归母净利润': PALETTE.coral,
|
||||||
|
'非GAAP调整项': PALETTE.blue,
|
||||||
|
'非GAAP归母净利润': PALETTE.red,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: '泡泡玛特利润流向分析(2026H1)',
|
||||||
|
subtext: '从营业收入到非GAAP归母净利润的完整流程,单位:亿元',
|
||||||
|
top: 18,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
triggerOn: 'mousemove',
|
||||||
|
textStyle: { fontFamily: FONT, fontSize: 13 },
|
||||||
|
formatter: (params) => {
|
||||||
|
if (params.dataType === 'edge') {
|
||||||
|
return `${params.data.source} → ${params.data.target}<br/><b>${params.data.value.toFixed(2)} 亿元</b>`;
|
||||||
|
} else {
|
||||||
|
return `<b>${params.name}</b><br/>总额: ${params.value.toFixed(2)} 亿元`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'sankey',
|
||||||
|
layout: 'none',
|
||||||
|
emphasis: {
|
||||||
|
focus: 'adjacency',
|
||||||
|
},
|
||||||
|
nodeAlign: 'left',
|
||||||
|
layoutIterations: 0,
|
||||||
|
nodeWidth: 26,
|
||||||
|
nodeGap: 14,
|
||||||
|
left: 120,
|
||||||
|
right: 180,
|
||||||
|
top: 110,
|
||||||
|
bottom: 90,
|
||||||
|
data: Object.keys(nodeColors).map(name => ({
|
||||||
|
name,
|
||||||
|
itemStyle: { color: nodeColors[name] },
|
||||||
|
})),
|
||||||
|
links,
|
||||||
|
label: {
|
||||||
|
color: PALETTE.ink,
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'normal',
|
||||||
|
formatter: (params) => {
|
||||||
|
// 给每个节点标注名称和数值
|
||||||
|
return `{name|${params.name}}\n{value|${params.value.toFixed(2)}亿}`;
|
||||||
|
},
|
||||||
|
rich: {
|
||||||
|
name: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT,
|
||||||
|
color: PALETTE.ink,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: FONT,
|
||||||
|
color: PALETTE.grey,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lineStyle: {
|
||||||
|
color: 'gradient',
|
||||||
|
curveness: 0.5,
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
levels: [
|
||||||
|
{
|
||||||
|
depth: 0,
|
||||||
|
label: { position: 'left' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
depth: 1,
|
||||||
|
label: { position: 'right' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
depth: 2,
|
||||||
|
label: { position: 'right' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
depth: 3,
|
||||||
|
label: { position: 'right' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
depth: 4,
|
||||||
|
label: { position: 'right' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
depth: 5,
|
||||||
|
label: { position: 'right' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote('数据来源:泡泡玛特财报 | @思考的Joey'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { col } from '../csv.js';
|
||||||
|
import {
|
||||||
|
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, legendStyle,
|
||||||
|
lineSeries, footnote, watermarks, LAYOUT,
|
||||||
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图 3:毛利率 vs 非国际净利率,两条线的简版对照
|
||||||
|
* 从 margin.csv 复用同一份数据,只挑两列
|
||||||
|
*/
|
||||||
|
export function twoLineChart(rows, { width, height }) {
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: { ...titleStyle, text: '毛利率与非国际净利率(半年与年度)' },
|
||||||
|
legend: {
|
||||||
|
...legendStyle,
|
||||||
|
top: LAYOUT.legendTop,
|
||||||
|
data: ['毛利率', '非国际归母净利率'].map(name => ({
|
||||||
|
name,
|
||||||
|
icon: 'emptyCircle', // 全部折线图
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
grid: { left: 72, right: 48, top: LAYOUT.gridTop, bottom: 62 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
valueFormatter: (v) => `${v}%`,
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
},
|
||||||
|
xAxis: { ...categoryAxis, data: col(rows, 'period') },
|
||||||
|
yAxis: {
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '比率(%)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 46,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: -5,
|
||||||
|
max: 80,
|
||||||
|
interval: 25,
|
||||||
|
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
lineSeries({
|
||||||
|
name: '毛利率',
|
||||||
|
data: col(rows, 'gross_margin'),
|
||||||
|
color: PALETTE.navy,
|
||||||
|
labelPosition: 'top',
|
||||||
|
formatter: (p) => `${p.value}%`,
|
||||||
|
width: 3.5,
|
||||||
|
}),
|
||||||
|
lineSeries({
|
||||||
|
name: '非国际归母净利率',
|
||||||
|
data: col(rows, 'non_gaap_net_margin'),
|
||||||
|
color: PALETTE.coral,
|
||||||
|
labelPosition: 'top',
|
||||||
|
formatter: (p) => `${p.value}%`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote('示例数据 | @思考的Joey'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 极简 CSV 解析:首行表头,逗号分隔,自动把数字列转成 number
|
||||||
|
* 不处理引号内的逗号 —— 财报数据用不上,保持简单
|
||||||
|
*/
|
||||||
|
export function parseCSV(text) {
|
||||||
|
const lines = text
|
||||||
|
.trim()
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((l) => l.trim() && !l.trim().startsWith('#'));
|
||||||
|
|
||||||
|
const headers = lines[0].split(',').map((h) => h.trim());
|
||||||
|
|
||||||
|
return lines.slice(1).map((line) => {
|
||||||
|
const cells = line.split(',').map((c) => c.trim());
|
||||||
|
const row = {};
|
||||||
|
headers.forEach((h, i) => {
|
||||||
|
const raw = cells[i];
|
||||||
|
const num = Number(raw);
|
||||||
|
row[h] = raw !== '' && !Number.isNaN(num) ? num : raw;
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCSV(path) {
|
||||||
|
const res = await fetch(path);
|
||||||
|
if (!res.ok) throw new Error(`读取 ${path} 失败:${res.status}`);
|
||||||
|
return parseCSV(await res.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取某一列 */
|
||||||
|
export function col(rows, key) {
|
||||||
|
return rows.map((r) => r[key]);
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
import { loadCSV } from './csv.js';
|
||||||
|
import { inventoryChart } from './charts/inventory.js';
|
||||||
|
import { marginChart } from './charts/margin.js';
|
||||||
|
import { twoLineChart } from './charts/twoline.js';
|
||||||
|
import { growthChart } from './charts/growth.js';
|
||||||
|
import { fxChart } from './charts/fx.js';
|
||||||
|
import { assetsChart } from './charts/ppmt_assets.js';
|
||||||
|
import { profitChart } from './charts/ppmt_profit.js';
|
||||||
|
import { ipChart } from './charts/ppmt_ip.js';
|
||||||
|
import { categoryChart } from './charts/ppmt_category.js';
|
||||||
|
import { sankeyChart } from './charts/ppmt_sankey.js';
|
||||||
|
import { regionChannelYoyChart } from './charts/ppmt_region_yoy.js';
|
||||||
|
import { ppmtGrowthChart } from './charts/ppmt_growth.js';
|
||||||
|
import { ppmtMarginsChart } from './charts/ppmt_margins.js';
|
||||||
|
import { ppmtBridgeChart } from './charts/ppmt_bridge.js';
|
||||||
|
import { ppmtCoreProfitChart } from './charts/ppmt_core_profit.js';
|
||||||
|
import { ppmtCoreMarginChart } from './charts/ppmt_core_margin.js';
|
||||||
|
import { ppmtIpShareChart } from './charts/ppmt_ip_share.js';
|
||||||
|
import { ppmtIpShareHistoryChart } from './charts/ppmt_ip_share_history.js';
|
||||||
|
import { ppmtIpExMonstersChart } from './charts/ppmt_ip_ex_monsters.js';
|
||||||
|
|
||||||
|
/** 图表清单:加新图只需在这里加一行 */
|
||||||
|
const CHARTS = [
|
||||||
|
// 示例图表
|
||||||
|
{ id: 'chart-inventory', csv: 'data/inventory.csv', build: inventoryChart, height: 620 },
|
||||||
|
{ id: 'chart-margin', csv: 'data/margin.csv', build: marginChart, height: 620 },
|
||||||
|
{ id: 'chart-twoline', csv: 'data/margin.csv', build: twoLineChart, height: 620 },
|
||||||
|
{ id: 'chart-growth', csv: 'data/growth.csv', build: growthChart, height: 700 },
|
||||||
|
{ id: 'chart-fx', csv: 'data/fx.csv', build: fxChart, height: 780 },
|
||||||
|
// 泡泡玛特图表
|
||||||
|
{ id: 'chart-ppmt-assets', csv: 'data/ppmt_assets.csv', build: assetsChart, height: 680 },
|
||||||
|
{ id: 'chart-ppmt-profit', csv: 'data/ppmt_profit.csv', build: profitChart, height: 680 },
|
||||||
|
{ id: 'chart-ppmt-ip', csv: 'data/ppmt_ip.csv', build: ipChart, height: 720 },
|
||||||
|
{ id: 'chart-ppmt-category', csv: 'data/ppmt_category.csv', build: categoryChart, height: 680 },
|
||||||
|
{ id: 'chart-ppmt-sankey', csv: 'data/ppmt_sankey_2026h1.csv', build: sankeyChart, height: 800 },
|
||||||
|
{ id: 'chart-ppmt-region-yoy', csv: 'data/ppmt_region_channel_yoy.csv', build: regionChannelYoyChart, height: 620 },
|
||||||
|
{ id: 'chart-ppmt-growth', csv: 'data/ppmt_growth.csv', build: ppmtGrowthChart, height: 700 },
|
||||||
|
{ id: 'chart-ppmt-margins', csv: 'data/ppmt_margins.csv', build: ppmtMarginsChart, height: 680 },
|
||||||
|
{ id: 'chart-ppmt-bridge', csv: 'data/ppmt_margin_bridge.csv', build: ppmtBridgeChart, height: 700 },
|
||||||
|
{ id: 'chart-ppmt-core', csv: 'data/ppmt_core_profit.csv', build: ppmtCoreProfitChart, height: 720 },
|
||||||
|
{ id: 'chart-ppmt-core-margin', csv: 'data/ppmt_core_margin.csv', build: ppmtCoreMarginChart, height: 680 },
|
||||||
|
{ id: 'chart-ppmt-ip-share', csv: 'data/ppmt_ip_share_2026h1.csv', build: ppmtIpShareChart, height: 720 },
|
||||||
|
{ id: 'chart-ppmt-ip-share-history', csv: 'data/ppmt_ip_share_history.csv', build: ppmtIpShareHistoryChart, height: 700 },
|
||||||
|
{ id: 'chart-ppmt-ip-ex-monsters', csv: 'data/ppmt_ip_ex_monsters.csv', build: ppmtIpExMonstersChart, height: 700 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const instances = new Map();
|
||||||
|
|
||||||
|
async function render(spec) {
|
||||||
|
const el = document.getElementById(spec.id);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 加时间戳绕过缓存,改完 CSV 刷新就能看到
|
||||||
|
const rows = await loadCSV(`${spec.csv}?t=${Date.now()}`);
|
||||||
|
const chart = instances.get(spec.id) || echarts.init(el, null, { renderer: 'canvas' });
|
||||||
|
instances.set(spec.id, chart);
|
||||||
|
|
||||||
|
const { width, height } = el.getBoundingClientRect();
|
||||||
|
chart.setOption(spec.build(rows, { width, height }), true);
|
||||||
|
} catch (err) {
|
||||||
|
el.innerHTML = `<div class="chart-error">${err.message}</div>`;
|
||||||
|
console.error(spec.id, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderAll() {
|
||||||
|
await Promise.all(CHARTS.map(render));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出当前图为 PNG,2 倍图,微信公众号里够清晰 */
|
||||||
|
function exportPNG(id, filename) {
|
||||||
|
const chart = instances.get(id);
|
||||||
|
if (!chart) return;
|
||||||
|
const url = chart.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${filename}.png`;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
renderAll();
|
||||||
|
|
||||||
|
document.getElementById('btn-reload').addEventListener('click', renderAll);
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-export]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
exportPNG(btn.dataset.export, btn.dataset.filename || btn.dataset.export);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let timer;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
// resize 后水印数量要跟着画布变,所以整体重绘而不是 chart.resize()
|
||||||
|
timer = setTimeout(renderAll, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
+247
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* 方木风格主题 —— 统一的配色、字体、坐标轴、水印
|
||||||
|
* 所有图表共用这一份配置,保证视觉一致性
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const WATERMARK_TEXT = '@思考的Joey';
|
||||||
|
|
||||||
|
// 字体名用单引号:SVG 渲染时字符串会被塞进 style="..." 属性,
|
||||||
|
// 用双引号会提前闭合属性、生成非法 SVG(canvas 渲染看不出来,导出 SVG 时才炸)
|
||||||
|
export const FONT = "'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif";
|
||||||
|
|
||||||
|
/** 调色板:低饱和、克制,接近财经媒体常用的莫兰迪系 */
|
||||||
|
export const PALETTE = {
|
||||||
|
brown: '#6b4a2b',
|
||||||
|
sand: '#d5c7b4',
|
||||||
|
coral: '#ec7b73',
|
||||||
|
green: '#2f8a5b',
|
||||||
|
orange: '#d98324',
|
||||||
|
purple: '#9b4fa8',
|
||||||
|
blue: '#3b6ea5',
|
||||||
|
navy: '#1f4e79',
|
||||||
|
red: '#b8342a',
|
||||||
|
sage: '#8aab7e',
|
||||||
|
ink: '#333333',
|
||||||
|
grey: '#8c8c8c',
|
||||||
|
gridLine: '#e8e8e8',
|
||||||
|
axisLine: '#d0d0d0',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 坐标轴通用样式:只留浅色横向网格,去掉轴线的存在感 */
|
||||||
|
export const axisCommon = {
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
color: PALETTE.grey,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: FONT,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: { color: PALETTE.gridLine, width: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** X 轴:显示轴线,不显示网格 */
|
||||||
|
export const categoryAxis = {
|
||||||
|
type: 'category',
|
||||||
|
boundaryGap: true,
|
||||||
|
axisLine: { show: true, lineStyle: { color: PALETTE.axisLine } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
color: PALETTE.ink,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: FONT,
|
||||||
|
margin: 14,
|
||||||
|
},
|
||||||
|
splitLine: { show: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 期间标签简写:2026H1 → 26H1,2026H2 → 26全年。
|
||||||
|
* CSV 里保留四位年份(无歧义、便于核对),只在轴上简化显示。
|
||||||
|
* 非 20xx 开头的标签(如"年初")原样返回。
|
||||||
|
*/
|
||||||
|
export function shortPeriod(label) {
|
||||||
|
return String(label)
|
||||||
|
.replace(/^20(\d{2})H2$/, '$1全年') // H2 = 全年累计
|
||||||
|
.replace(/^20(\d{2})/, '$1'); // 其余去掉世纪前缀
|
||||||
|
}
|
||||||
|
|
||||||
|
/** X 轴(期间):在 categoryAxis 基础上套用简写 */
|
||||||
|
export const periodAxis = {
|
||||||
|
...categoryAxis,
|
||||||
|
axisLabel: {
|
||||||
|
...categoryAxis.axisLabel,
|
||||||
|
formatter: shortPeriod,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const titleStyle = {
|
||||||
|
left: 'center',
|
||||||
|
top: 18,
|
||||||
|
textStyle: {
|
||||||
|
color: PALETTE.ink,
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontFamily: FONT,
|
||||||
|
},
|
||||||
|
subtextStyle: {
|
||||||
|
color: PALETTE.grey,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: FONT,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准布局常量
|
||||||
|
* 经多图表调试验证的标题-图例-绘图区间距,确保视觉呼吸感一致
|
||||||
|
*/
|
||||||
|
export const LAYOUT = {
|
||||||
|
titleTop: 18, // 标题顶部边距(已内置在 titleStyle 中)
|
||||||
|
legendTop: 85, // 图例顶部边距(副标题到图例约 25px)
|
||||||
|
gridTop: 145, // 绘图区顶部边距(图例到绘图区约 60px)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const legendStyle = {
|
||||||
|
top: 62, // ⚠️ 已废弃:请改用 LAYOUT.legendTop 或 standardLegend()
|
||||||
|
itemGap: 26,
|
||||||
|
itemWidth: 18,
|
||||||
|
// ❌ 删除 icon: 'roundRect'
|
||||||
|
// 原因:它会覆盖 ECharts 按系列类型自动生成的图例样式(折线图的"短线+空心圆")
|
||||||
|
// 现在让每个图表按需设置,或使用 standardLegend() 获得正确默认值
|
||||||
|
textStyle: {
|
||||||
|
color: PALETTE.ink,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: FONT,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 折线标注:带白底描边,避免压在网格线上看不清 */
|
||||||
|
export function labelStyle(color, position = 'top') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
position,
|
||||||
|
color,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontWeight: 500,
|
||||||
|
distance: 8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准图例配置
|
||||||
|
* 不设置 icon,让 ECharts 按系列类型自动生成(折线→短线+空心圆,柱状→矩形)
|
||||||
|
* 统一使用 LAYOUT.legendTop 保证间距一致
|
||||||
|
*/
|
||||||
|
export function standardLegend(data, options = {}) {
|
||||||
|
return {
|
||||||
|
top: LAYOUT.legendTop,
|
||||||
|
itemGap: options.itemGap ?? 28,
|
||||||
|
textStyle: {
|
||||||
|
color: PALETTE.ink,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: FONT,
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
...options, // 允许覆盖任何字段
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空心圆点折线系列 —— 标准配置
|
||||||
|
* 白心 + 彩色边框,视觉统一(symbolSize: 9, borderWidth: 2.5)
|
||||||
|
*/
|
||||||
|
export function lineSeries({ name, data, color, labelPosition = 'top', formatter, width = 3 }) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
type: 'line',
|
||||||
|
data,
|
||||||
|
smooth: false,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 9,
|
||||||
|
itemStyle: {
|
||||||
|
color: '#fff',
|
||||||
|
borderColor: color,
|
||||||
|
borderWidth: 2.5,
|
||||||
|
},
|
||||||
|
lineStyle: { color, width },
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
label: {
|
||||||
|
...labelStyle(color, labelPosition),
|
||||||
|
formatter,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 柱状图系列标准配置
|
||||||
|
* 强制显示所有标签(overflow: 'none'),避免小柱子标签被 ECharts 自动隐藏
|
||||||
|
*/
|
||||||
|
export function barSeries({ name, data, color, barWidth, formatter, ...rest }) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
type: 'bar',
|
||||||
|
data,
|
||||||
|
barWidth,
|
||||||
|
itemStyle: { color },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontSize: 11,
|
||||||
|
color: PALETTE.ink,
|
||||||
|
formatter: formatter ?? ((p) => p.value),
|
||||||
|
overflow: 'none', // 关键:强制显示所有标签
|
||||||
|
},
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 底部注释 */
|
||||||
|
export function footnote(text) {
|
||||||
|
return {
|
||||||
|
type: 'text',
|
||||||
|
left: 'center',
|
||||||
|
bottom: 12,
|
||||||
|
style: {
|
||||||
|
text,
|
||||||
|
fill: PALETTE.grey,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平铺水印。ECharts 没有原生水印,用 graphic 数组铺满画布。
|
||||||
|
* 旋转 -20°,透明度压到 0.08,不干扰读数。
|
||||||
|
*/
|
||||||
|
export function watermarks(width, height, text = WATERMARK_TEXT) {
|
||||||
|
const items = [];
|
||||||
|
const stepX = 300;
|
||||||
|
const stepY = 200;
|
||||||
|
for (let x = -100; x < width + stepX; x += stepX) {
|
||||||
|
for (let y = 40; y < height + stepY; y += stepY) {
|
||||||
|
const offset = (Math.floor(y / stepY) % 2) * (stepX / 2);
|
||||||
|
items.push({
|
||||||
|
type: 'text',
|
||||||
|
left: x + offset,
|
||||||
|
top: y,
|
||||||
|
silent: true,
|
||||||
|
rotation: 0.35,
|
||||||
|
style: {
|
||||||
|
text,
|
||||||
|
fill: 'rgba(120, 100, 80, 0.10)',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontFamily: FONT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
Generated
+44
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "echart-demo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "echart-demo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"echarts": "^5.5.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/echarts": {
|
||||||
|
"version": "5.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.5.1.tgz",
|
||||||
|
"integrity": "sha512-Fce8upazaAXUVUVsjgV6mBnGuqgO+JNDlcgF79Dksy4+wgGpQB2lmYoO4TSweFg/mZITdpGHomw/cNBJZj1icA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0",
|
||||||
|
"zrender": "5.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/zrender": {
|
||||||
|
"version": "5.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.0.tgz",
|
||||||
|
"integrity": "sha512-uzgraf4njmmHAbEUxMJ8Oxg+P3fT04O+9p7gY+wJRVxo8Ge+KmYv0WJev945EH4wFuc4OY2NLXz46FZrWS9xJg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "echart-demo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "财报图表 · 基于 ECharts 和 CSV 数据",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "python3 -m http.server 8899",
|
||||||
|
"dev": "python3 -m http.server 8899",
|
||||||
|
"test": "node test/regress.mjs"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"echarts",
|
||||||
|
"charts",
|
||||||
|
"data-visualization",
|
||||||
|
"财报"
|
||||||
|
],
|
||||||
|
"author": "Joey",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"echarts": "^5.5.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import * as echarts from 'echarts';
|
||||||
|
import fs from 'fs';
|
||||||
|
// 项目根目录(从 test/regress.mjs 向上一层,或直接指定绝对路径)
|
||||||
|
const P = '/Users/joey/sites/vest-tools/echart-demo/';
|
||||||
|
const {parseCSV}=await import(P+'js/csv.js');
|
||||||
|
const all=[
|
||||||
|
['inventory','inventoryChart','data/inventory.csv'],
|
||||||
|
['margin','marginChart','data/margin.csv'],
|
||||||
|
['twoline','twoLineChart','data/margin.csv'],
|
||||||
|
['growth','growthChart','data/growth.csv'],
|
||||||
|
['fx','fxChart','data/fx.csv'],
|
||||||
|
['ppmt_assets','assetsChart','data/ppmt_assets.csv'],
|
||||||
|
['ppmt_profit','profitChart','data/ppmt_profit.csv'],
|
||||||
|
['ppmt_ip','ipChart','data/ppmt_ip.csv'],
|
||||||
|
['ppmt_category','categoryChart','data/ppmt_category.csv'],
|
||||||
|
['ppmt_sankey','sankeyChart','data/ppmt_sankey_2026h1.csv'],
|
||||||
|
['ppmt_region_yoy','regionChannelYoyChart','data/ppmt_region_channel_yoy.csv'],
|
||||||
|
['ppmt_growth','ppmtGrowthChart','data/ppmt_growth.csv'],
|
||||||
|
['ppmt_margins','ppmtMarginsChart','data/ppmt_margins.csv'],
|
||||||
|
['ppmt_bridge','ppmtBridgeChart','data/ppmt_margin_bridge.csv'],
|
||||||
|
['ppmt_core_profit','ppmtCoreProfitChart','data/ppmt_core_profit.csv'],
|
||||||
|
['ppmt_core_margin','ppmtCoreMarginChart','data/ppmt_core_margin.csv'],
|
||||||
|
['ppmt_ip_share','ppmtIpShareChart','data/ppmt_ip_share_2026h1.csv'],
|
||||||
|
['ppmt_ip_share_history','ppmtIpShareHistoryChart','data/ppmt_ip_share_history.csv'],
|
||||||
|
['ppmt_ip_ex_monsters','ppmtIpExMonstersChart','data/ppmt_ip_ex_monsters.csv'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// 可传图表名只跑指定的(改一张图不用全量回归):
|
||||||
|
// node test/regress.mjs ppmt_ip_share ppmt_core_margin
|
||||||
|
const only=process.argv.slice(2);
|
||||||
|
const targets=only.length>0 ? all.filter(([f])=>only.includes(f)) : all;
|
||||||
|
if(only.length>0){
|
||||||
|
const missing=only.filter(f=>!all.some(([name])=>name===f));
|
||||||
|
if(missing.length>0) console.log(`⚠ 未找到图表:${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 样式规范断言:从核心利润/利润率拆解图总结的标准
|
||||||
|
const STYLE_RULES={
|
||||||
|
legendTop:85, // 固定值:副标题到图例约25px
|
||||||
|
gridTop:145, // 固定值:图例到绘图区约60px
|
||||||
|
lineSeries:{ // 折线标准样式(空心圆点)
|
||||||
|
symbolSize:9,
|
||||||
|
borderWidth:2.5,
|
||||||
|
fill:'#fff', // 白心
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let ok=0,fail=0,styleWarnings=[];
|
||||||
|
for(const [f,fn,csv] of targets){
|
||||||
|
try{
|
||||||
|
const mod=await import(P+`js/charts/${f}.js`);
|
||||||
|
const rows=parseCSV(fs.readFileSync(P+csv,'utf8'));
|
||||||
|
const c=echarts.init(null,null,{renderer:'svg',ssr:true,width:1100,height:720});
|
||||||
|
const opt=mod[fn](rows,{width:1100,height:720});
|
||||||
|
c.setOption(opt);
|
||||||
|
const svg=c.renderToSVGString();
|
||||||
|
|
||||||
|
// 1. SVG 格式检查(字体引号)
|
||||||
|
const badAttr=/style="[^"]*"[^"=>\s][^>]*>/.test(svg.slice(0,3000));
|
||||||
|
|
||||||
|
// 2. 样式规范检查(跳过无图例的图表)
|
||||||
|
if(opt.legend?.top!==undefined){
|
||||||
|
if(opt.legend.top!==STYLE_RULES.legendTop){
|
||||||
|
styleWarnings.push(`${f}: legend.top=${opt.legend.top} (期望${STYLE_RULES.legendTop})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(opt.grid?.top!==undefined && opt.legend?.top!==undefined){
|
||||||
|
if(opt.grid.top!==STYLE_RULES.gridTop){
|
||||||
|
styleWarnings.push(`${f}: grid.top=${opt.grid.top} (期望${STYLE_RULES.gridTop})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 折线圆点样式检查(在 SVG 里验证 symbolSize 和 borderWidth)
|
||||||
|
const circles=[...svg.matchAll(/<path d="M1 0A1[^"]*" transform="matrix\(([\d.]+),0,0,[\d.]+,[^)]+\)" fill="#fff" stroke="[^"]*" stroke-width="([\d.]+)"/g)];
|
||||||
|
if(circles.length>0){
|
||||||
|
const scale=+circles[0][1], sw=+circles[0][2];
|
||||||
|
const symbolSize=scale*2, borderWidth=sw*scale;
|
||||||
|
if(Math.abs(symbolSize-STYLE_RULES.lineSeries.symbolSize)>0.1){
|
||||||
|
styleWarnings.push(`${f}: 折线 symbolSize=${symbolSize.toFixed(1)} (期望${STYLE_RULES.lineSeries.symbolSize})`);
|
||||||
|
}
|
||||||
|
if(Math.abs(borderWidth-STYLE_RULES.lineSeries.borderWidth)>0.1){
|
||||||
|
styleWarnings.push(`${f}: 折线 borderWidth=${borderWidth.toFixed(1)} (期望${STYLE_RULES.lineSeries.borderWidth})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${badAttr?'⚠':'✓'} ${f.padEnd(17)} ${String(svg.length).padStart(6)}B`);
|
||||||
|
ok++; c.dispose();
|
||||||
|
}catch(e){console.log(`✗ ${f.padEnd(17)} ${e.message}`);fail++;}
|
||||||
|
}
|
||||||
|
console.log(`\n${ok} 张成功 / ${fail} 张失败`);
|
||||||
|
|
||||||
|
if(styleWarnings.length>0){
|
||||||
|
console.log(`\n⚠ ${styleWarnings.length} 处样式偏离规范:`);
|
||||||
|
styleWarnings.forEach(w=>console.log(` ${w}`));
|
||||||
|
}else{
|
||||||
|
console.log('✓ 样式规范全部符合');
|
||||||
|
}
|
||||||
|
process.exit(fail>0?1:0);
|
||||||
Reference in New Issue
Block a user