feat: 更新泡泡玛特图表,加入单店收入
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user