122 lines
3.9 KiB
JavaScript
122 lines
3.9 KiB
JavaScript
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'),
|
||
],
|
||
};
|
||
}
|