Files
2026-08-23 21:23:41 +08:00

128 lines
4.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
),
],
};
}