feat: 构建泡泡玛特相关图表

This commit is contained in:
Joey
2026-08-23 21:23:41 +08:00
commit 3ae396776d
31 changed files with 3454 additions and 0 deletions
+127
View File
@@ -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'
),
],
};
}