feat: 更新泡泡玛特图表,加入单店收入

This commit is contained in:
2026-09-06 22:31:55 +08:00
parent 2555453b79
commit b3c7c30a1f
22 changed files with 566 additions and 30 deletions
+8
View File
@@ -28,6 +28,10 @@ import { channelRevenueChart } from './ppmt_channel_revenue.js';
import { storesRetailChart } from './ppmt_stores_retail.js';
import { storesRobotChart } from './ppmt_stores_robot.js';
import { regionShareChart } from './ppmt_region_share.js';
import { inventoryBalanceChart } from './ppmt_inventory.js';
import { storesRetailShareChart } from './ppmt_stores_retail_share.js';
import { retailStoreRevenueChart } from './ppmt_retail_store_revenue.js';
import { robotStoreRevenueChart } from './ppmt_robot_store_revenue.js';
/** 图表构建函数注册表。manifest 只引用这里的 key,不直接引用 JS 函数。 */
export const chartRegistry = {
@@ -61,6 +65,10 @@ export const chartRegistry = {
'ppmt-stores-retail': storesRetailChart,
'ppmt-stores-robot': storesRobotChart,
'ppmt-region-share': regionShareChart,
'ppmt-inventory': inventoryBalanceChart,
'ppmt-stores-retail-share': storesRetailShareChart,
'ppmt-retail-store-revenue': retailStoreRevenueChart,
'ppmt-robot-store-revenue': robotStoreRevenueChart,
};
export function getChartBuilder(key) {
+6 -2
View File
@@ -31,6 +31,10 @@ export function channelRevenueChart(rows, { width, height, view = 'all' }) {
const totalMax = Math.max(...rows.map((row) => num(row.total_revenue) || 0));
const axisMax = NICE_MAX.find((max) => max >= totalMax * 1.02)
?? Math.ceil((totalMax * 1.2) / 100) * 100;
const yoyValues = col(rows, 'revenue_yoy').filter(Number.isFinite);
// 全部/半年视图存在 2022H2 的负增长,右轴需包含负区间,避免折线被 0 轴裁切。
const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 50) * 50);
const yoyMax = Math.max(50, Math.ceil(Math.max(...yoyValues) / 50) * 50);
// 绘图区像素高:画布高 − grid top 145 − grid bottom 90
const pxPerUnit = (height - LAYOUT.gridTop - 90) / axisMax;
@@ -111,8 +115,8 @@ export function channelRevenueChart(rows, { width, height, view = 'all' }) {
nameGap: 60,
nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
max: 250,
min: yoyMin,
max: yoyMax,
interval: 50,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
+82
View File
@@ -0,0 +1,82 @@
import { col } from '../csv.js';
import {
FINANCIAL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend,
financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/** 泡泡玛特存货余额与同报告期同比:存货为期末时点数。 */
export function inventoryBalanceChart(rows, { width, height, view = 'all' }) {
const yoy = col(rows, 'yoy').filter(Number.isFinite);
const yoyMin = Math.min(0, Math.floor(Math.min(...yoy) / 20) * 20);
const yoyMax = Math.max(50, Math.ceil(Math.max(...yoy) / 50) * 50);
const barWidth = view === 'all' ? 28.8 : 60.8;
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: `泡泡玛特存货余额及同比(${financialViewLabel(view)})`,
subtext: '柱状为报告期末存货余额;折线为同报告期同比(H1 对 H1、全年对全年)',
},
legend: standardLegend(['存货余额', '存货同比']),
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
valueFormatter: (value) => `${value.toFixed(1)}`,
},
xAxis: { ...standalonePeriodAxis, data: col(rows, 'period') },
yAxis: [
{
...axisCommon,
type: 'value',
name: '存货余额(亿元)',
nameLocation: 'middle',
nameGap: 52,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
},
{
...axisCommon,
type: 'value',
name: '同比(%)',
nameLocation: 'middle',
nameGap: 58,
nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: yoyMin,
max: yoyMax,
interval: (yoyMax - yoyMin) / 5,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
},
],
series: [
{
...barSeries({
name: '存货余额',
data: col(rows, 'inventory'),
color: FINANCIAL_COLORS.investingCashFlow,
barWidth,
formatter: (point) => point.value.toFixed(1),
}),
emphasis: { focus: 'series' },
},
{
...lineSeries({
name: '存货同比',
data: col(rows, 'yoy'),
color: FINANCIAL_COLORS.cashConversion,
formatter: (point) => `${point.value.toFixed(1)}%`,
}),
yAxisIndex: 1,
z: 10,
},
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特历年合并资产负债表;存货为报告期末时点数 | @思考的Joey'),
],
};
}
+110
View File
@@ -0,0 +1,110 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend,
financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
const REGIONS = [
{ key: 'china', name: '中国(含港澳台)', color: '#3F7FBE' },
{ key: 'apac', name: '亚太', color: '#36A28F' },
{ key: 'americas', name: '美洲', color: '#E4A349' },
{ key: 'europe_other', name: '欧洲及其他', color: PALETTE.brown },
];
function yoyByPeriod(rows, key) {
const values = new Map(rows.map((row) => [row.period, row[key]]));
return rows.map((row) => {
const year = Number(row.period.slice(0, 4));
const prior = values.get(`${year - 1}${row.period.slice(4)}`);
return Number.isFinite(row[key]) && Number.isFinite(prior) && prior !== 0
? (row[key] / prior - 1) * 100
: null;
});
}
/** 各地区零售店单店收入:收入 ÷ 期初、期末平均零售店数。 */
export function retailStoreRevenueChart(rows, { width, height, view = 'all' }) {
const values = REGIONS.flatMap((region) => col(rows, `${region.key}_per_store`)).filter(Number.isFinite);
const yMax = Math.ceil(Math.max(...values) / 1000) * 1000;
const yoySeries = REGIONS.map((region) => ({ ...region, data: yoyByPeriod(rows, `${region.key}_per_store`) }));
const yoyValues = yoySeries.flatMap((region) => region.data).filter(Number.isFinite);
const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 50) * 50);
const yoyMax = Math.max(50, Math.ceil(Math.max(...yoyValues) / 50) * 50);
const legendItems = REGIONS.flatMap((region) => [region.name, `${region.name}同比`]);
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: `泡泡玛特各地区零售店单店收入(${financialViewLabel(view)})`,
subtext: '柱状为单店收入;同色虚线为单店收入同比;H2为下半年单期',
},
legend: standardLegend(legendItems, { itemGap: 18 }),
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
formatter: (params) => `<b>${params[0].axisValue}</b><br/>${params
.filter((item) => Number.isFinite(item.value))
.map((item) => `${item.marker}${item.seriesName}: ${Number(item.value).toFixed(1)}${item.seriesName.endsWith('同比') ? '%' : ' 万元/店'}`)
.join('<br/>')}`,
},
xAxis: { ...standalonePeriodAxis, data: col(rows, 'period') },
yAxis: [
{
...axisCommon,
type: 'value',
name: '单店收入(万元/店)',
nameLocation: 'middle',
nameGap: 58,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
max: yMax,
interval: yMax / 5,
},
{
...axisCommon,
type: 'value',
name: '单店收入同比(%)',
nameLocation: 'middle',
nameGap: 60,
nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: yoyMin,
max: yoyMax,
interval: 50,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
},
],
series: [
...REGIONS.map((region) => ({
...barSeries({
name: region.name,
data: col(rows, `${region.key}_per_store`),
color: region.color,
barWidth: view === 'all' ? 17 : 34,
formatter: (point) => point.value.toFixed(0),
}),
emphasis: { focus: 'series' },
})),
...yoySeries.map((region) => ({
...lineSeries({
name: `${region.name}同比`,
data: region.data,
color: region.color,
width: 2,
formatter: (point) => `${point.value.toFixed(0)}%`,
}),
yAxisIndex: 1,
lineStyle: { color: region.color, width: 2, type: 'dashed' },
z: 10,
})),
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特历年公告;收入仅含“零售店”渠道;H2收入=全年−H1\n2024年海外各区域缺少期初店数,相关单店收入留空 | @思考的Joey'),
],
};
}
+108
View File
@@ -0,0 +1,108 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend,
financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
const REGIONS = [
{ key: 'china', name: '中国(含港澳台)', color: '#71AD81' },
{ key: 'overseas', name: '海外', color: PALETTE.brown },
];
function yoyByPeriod(rows, key) {
const values = new Map(rows.map((row) => [row.period, row[key]]));
return rows.map((row) => {
const year = Number(row.period.slice(0, 4));
const prior = values.get(`${year - 1}${row.period.slice(4)}`);
return Number.isFinite(row[key]) && Number.isFinite(prior) && prior !== 0
? (row[key] / prior - 1) * 100
: null;
});
}
/** 中国与海外机器人商店单店收入:收入 ÷ 期初、期末平均机器人商店数。 */
export function robotStoreRevenueChart(rows, { width, height, view = 'all' }) {
const values = REGIONS.flatMap((region) => col(rows, `${region.key}_per_store`)).filter(Number.isFinite);
const yMax = Math.ceil(Math.max(...values) / 50) * 50;
const yoySeries = REGIONS.map((region) => ({ ...region, data: yoyByPeriod(rows, `${region.key}_per_store`) }));
const yoyValues = yoySeries.flatMap((region) => region.data).filter(Number.isFinite);
const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 50) * 50);
const yoyMax = Math.max(50, Math.ceil(Math.max(...yoyValues) / 50) * 50);
const legendItems = REGIONS.flatMap((region) => [region.name, `${region.name}同比`]);
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: `泡泡玛特机器人商店单店收入(${financialViewLabel(view)})`,
subtext: '柱状为单店收入;同色虚线为单店收入同比;H2为下半年单期',
},
legend: standardLegend(legendItems, { itemGap: 18 }),
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
formatter: (params) => `<b>${params[0].axisValue}</b><br/>${params
.filter((item) => Number.isFinite(item.value))
.map((item) => `${item.marker}${item.seriesName}: ${Number(item.value).toFixed(1)}${item.seriesName.endsWith('同比') ? '%' : ' 万元/店'}`)
.join('<br/>')}`,
},
xAxis: { ...standalonePeriodAxis, data: col(rows, 'period') },
yAxis: [
{
...axisCommon,
type: 'value',
name: '单店收入(万元/店)',
nameLocation: 'middle',
nameGap: 58,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
max: yMax,
interval: yMax / 5,
},
{
...axisCommon,
type: 'value',
name: '单店收入同比(%)',
nameLocation: 'middle',
nameGap: 60,
nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: yoyMin,
max: yoyMax,
interval: 50,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
},
],
series: [
...REGIONS.map((region) => ({
...barSeries({
name: region.name,
data: col(rows, `${region.key}_per_store`),
color: region.color,
barWidth: view === 'all' ? 28 : 52,
formatter: (point) => point.value.toFixed(0),
}),
emphasis: { focus: 'series' },
})),
...yoySeries.map((region) => ({
...lineSeries({
name: `${region.name}同比`,
data: region.data,
color: region.color,
width: 2,
formatter: (point) => `${point.value.toFixed(0)}%`,
}),
yAxisIndex: 1,
lineStyle: { color: region.color, width: 2, type: 'dashed' },
z: 10,
})),
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特历年公告;海外收入=亚太+美洲+欧洲及其他的机器人商店收入;H2收入=全年−H1\n2024H1海外期末机器人商店数未披露,相关单店收入留空 | @思考的Joey'),
],
};
}
+83
View File
@@ -0,0 +1,83 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend,
financialViewLabel, barSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/** 泡泡玛特分地区零售门店占比:每根柱标准化为各期末全球门店数的 100%。 */
const REGIONS = [
{ key: 'china', name: '中国(含港澳台)', color: '#3F7FBE', labelColor: '#fff' },
{ key: 'apac', name: '亚太', color: '#36A28F', labelColor: '#fff' },
{ key: 'americas', name: '美洲', color: '#E4A349', labelColor: PALETTE.ink },
{ key: 'europe', name: '欧洲', color: PALETTE.brown, labelColor: '#fff' },
{ key: 'other', name: '其他', color: PALETTE.sand, labelColor: PALETTE.ink },
];
function sharePoint(value, labelColor) {
if (!Number.isFinite(value) || value === 0) return null;
return {
value,
label: value >= 5
? { position: 'inside', color: labelColor }
: { show: false },
};
}
export function storesRetailShareChart(rows, { width, height, view = 'all' }) {
const periods = col(rows, 'period');
const barWidth = view === 'all' ? 48 : 80;
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: `泡泡玛特分地区零售门店数量占比(${financialViewLabel(view)})`,
subtext: '归一化堆叠:每根柱为当期末全球零售门店数的 100%',
},
legend: standardLegend(REGIONS.map((item) => item.name), { itemGap: 20 }),
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
formatter: (params) => {
const row = rows[periods.indexOf(params[0].axisValue)];
return `<b>${params[0].axisValue}</b> 全球合计 ${row.global} 家<br/>${params
.filter((item) => item.value !== null)
.slice()
.reverse()
.map((item) => `${item.marker}${item.seriesName}: ${item.value.toFixed(1)}%`)
.join('<br/>')}`;
},
},
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 100 },
xAxis: { ...standalonePeriodAxis, data: periods },
yAxis: {
...axisCommon,
type: 'value',
min: 0,
max: 100,
interval: 20,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
},
series: REGIONS.map((item) => barSeries({
name: item.name,
data: rows.map((row) => sharePoint(row[item.key], item.labelColor)),
color: item.color,
barWidth,
stack: 'stores-retail-share',
label: {
show: true,
position: 'inside',
fontSize: 11,
fontFamily: FONT,
fontWeight: 500,
formatter: (point) => `${point.value.toFixed(1)}%`,
},
emphasis: { focus: 'series' },
})),
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特历年公告;门店数为各报告期末时点数\n中国=中国内地+港澳台;其他=全球合计−中国−亚太−美洲−欧洲 | @思考的Joey'),
],
};
}
+2 -2
View File
@@ -36,7 +36,7 @@ export function workingCapitalDaysChart(rows, { width, height }) {
title: {
...titleStyle,
text: '泡泡玛特营运资本效率',
subtext: '现金转换周期 = 存货周转天数 + 应收周转天数 − 应付周转天数',
subtext: '现金转换周期 = 存货周转天数 + 应收周转天数 − 应付周转天数(中报按181天、年报按365天)',
},
legend: { ...legendStyle, top: LAYOUT.legendTop, data: SERIES.map((item) => item.name) },
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
@@ -78,7 +78,7 @@ export function workingCapitalDaysChart(rows, { width, height }) {
})),
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特财务指标(合并报表)| @思考的Joey'),
footnote('数据来源:泡泡玛特财务指标(合并报表);原始180/360天口径已换算为181/365天 | @思考的Joey'),
],
};
}