feat(ppmt): 新增分地区收入占比与门店数量图表

- 新增分地区收入占比、零售门店数量、机器人商店数量三张图表及对应 CSV 数据
- 渠道收入图矮段标签外置并扇形排布,避免段内标签挤压
- IP 收入占比图改用 standardLegend 统一图例样式
- 折线标注增加白底描边,提升可读性
This commit is contained in:
R524809
2026-08-31 16:31:44 +08:00
parent d51890a450
commit 2555453b79
17 changed files with 573 additions and 35 deletions
+8
View File
@@ -0,0 +1,8 @@
period,china,apac,americas,europe,total
2024H1,35.22,7.97,1.82,0.58,45.59
2024H2,53.65,23.13,6.20,1.81,84.79
2024FY,88.86,31.10,8.02,2.39,130.37
2025H1,82.83,28.51,22.65,4.77,138.76
2025H2,125.69,51.60,45.41,9.74,232.44
2025FY,208.52,80.11,68.06,14.51,371.20
2026H1,122.01,25.75,18.92,5.06,171.74
1 period china apac americas europe total
2 2024H1 35.22 7.97 1.82 0.58 45.59
3 2024H2 53.65 23.13 6.20 1.81 84.79
4 2024FY 88.86 31.10 8.02 2.39 130.37
5 2025H1 82.83 28.51 22.65 4.77 138.76
6 2025H2 125.69 51.60 45.41 9.74 232.44
7 2025FY 208.52 80.11 68.06 14.51 371.20
8 2026H1 122.01 25.75 18.92 5.06 171.74
+3
View File
@@ -0,0 +1,3 @@
period,china,apac,americas,europe,total
2024FY,88.86,31.10,8.02,2.39,130.37
2025FY,208.52,80.11,68.06,14.51,371.20
1 period china apac americas europe total
2 2024FY 88.86 31.10 8.02 2.39 130.37
3 2025FY 208.52 80.11 68.06 14.51 371.20
+6
View File
@@ -0,0 +1,6 @@
period,china,apac,americas,europe,total
2024H1,35.22,7.97,1.82,0.58,45.59
2024H2,53.65,23.13,6.20,1.81,84.79
2025H1,82.83,28.51,22.65,4.77,138.76
2025H2,125.69,51.60,45.41,9.74,232.44
2026H1,122.01,25.75,18.92,5.06,171.74
1 period china apac americas europe total
2 2024H1 35.22 7.97 1.82 0.58 45.59
3 2024H2 53.65 23.13 6.20 1.81 84.79
4 2025H1 82.83 28.51 22.65 4.77 138.76
5 2025H2 125.69 51.60 45.41 9.74 232.44
6 2026H1 122.01 25.75 18.92 5.06 171.74
+13
View File
@@ -0,0 +1,13 @@
period,mainland,hkmtw,apac,americas,europe,overseas,global,global_yoy
2017FY,32,,,,,,32,
2018FY,63,,,,,,63,96.9
2019FY,114,,,,,,114,81.0
2020FY,187,,,,,,187,64.0
2021FY,288,,,,,7,295,57.8
2022FY,329,,,,,43,372,26.1
2023FY,363,,,,,70,433,16.4
2024H1,374,25,39,10,9,58,457,
2024FY,401,30,54,22,14,90,521,20.3
2025H1,409,34,69,41,18,128,571,24.9
2025FY,410,35,85,64,36,185,630,20.9
2026H1,419,36,90,86,45,221,676,18.4
1 period mainland hkmtw apac americas europe overseas global global_yoy
2 2017FY 32 32
3 2018FY 63 63 96.9
4 2019FY 114 114 81.0
5 2020FY 187 187 64.0
6 2021FY 288 7 295 57.8
7 2022FY 329 43 372 26.1
8 2023FY 363 70 433 16.4
9 2024H1 374 25 39 10 9 58 457
10 2024FY 401 30 54 22 14 90 521 20.3
11 2025H1 409 34 69 41 18 128 571 24.9
12 2025FY 410 35 85 64 36 185 630 20.9
13 2026H1 419 36 90 86 45 221 676 18.4
+10
View File
@@ -0,0 +1,10 @@
period,mainland,hkmtw,apac,americas,europe,overseas,global,global_yoy
2017FY,32,,,,,,32,
2018FY,63,,,,,,63,96.9
2019FY,114,,,,,,114,81.0
2020FY,187,,,,,,187,64.0
2021FY,288,,,,,7,295,57.8
2022FY,329,,,,,43,372,26.1
2023FY,363,,,,,70,433,16.4
2024FY,401,30,54,22,14,90,521,20.3
2025FY,410,35,85,64,36,185,630,20.9
1 period mainland hkmtw apac americas europe overseas global global_yoy
2 2017FY 32 32
3 2018FY 63 63 96.9
4 2019FY 114 114 81.0
5 2020FY 187 187 64.0
6 2021FY 288 7 295 57.8
7 2022FY 329 43 372 26.1
8 2023FY 363 70 433 16.4
9 2024FY 401 30 54 22 14 90 521 20.3
10 2025FY 410 35 85 64 36 185 630 20.9
@@ -0,0 +1,4 @@
period,mainland,hkmtw,apac,americas,europe,overseas,global,global_yoy
2024H1,374,25,39,10,9,58,457,
2025H1,409,34,69,41,18,128,571,24.9
2026H1,419,36,90,86,45,221,676,18.4
1 period mainland hkmtw apac americas europe overseas global global_yoy
2 2024H1 374 25 39 10 9 58 457
3 2025H1 409 34 69 41 18 128 571 24.9
4 2026H1 419 36 90 86 45 221 676 18.4
+13
View File
@@ -0,0 +1,13 @@
period,mainland,hkmtw,overseas,global,global_yoy
2017FY,43,,,43,
2018FY,260,,,260,504.7
2019FY,825,,,825,217.3
2020FY,1351,,,1351,63.8
2021FY,1861,,9,1870,38.4
2022FY,2067,,120,2187,17.0
2023FY,2190,,99,2289,4.7
2024H1,2189,35,,,
2024FY,2300,39,133,2472,8.0
2025H1,2396,41,160,2597,
2025FY,2350,46,241,2637,6.7
2026H1,2436,46,345,2827,8.9
1 period mainland hkmtw overseas global global_yoy
2 2017FY 43 43
3 2018FY 260 260 504.7
4 2019FY 825 825 217.3
5 2020FY 1351 1351 63.8
6 2021FY 1861 9 1870 38.4
7 2022FY 2067 120 2187 17.0
8 2023FY 2190 99 2289 4.7
9 2024H1 2189 35
10 2024FY 2300 39 133 2472 8.0
11 2025H1 2396 41 160 2597
12 2025FY 2350 46 241 2637 6.7
13 2026H1 2436 46 345 2827 8.9
+10
View File
@@ -0,0 +1,10 @@
period,mainland,hkmtw,overseas,global,global_yoy
2017FY,43,,,43,
2018FY,260,,,260,504.7
2019FY,825,,,825,217.3
2020FY,1351,,,1351,63.8
2021FY,1861,,9,1870,38.4
2022FY,2067,,120,2187,17.0
2023FY,2190,,99,2289,4.7
2024FY,2300,39,133,2472,8.0
2025FY,2350,46,241,2637,6.7
1 period mainland hkmtw overseas global global_yoy
2 2017FY 43 43
3 2018FY 260 260 504.7
4 2019FY 825 825 217.3
5 2020FY 1351 1351 63.8
6 2021FY 1861 9 1870 38.4
7 2022FY 2067 120 2187 17.0
8 2023FY 2190 99 2289 4.7
9 2024FY 2300 39 133 2472 8.0
10 2025FY 2350 46 241 2637 6.7
+4
View File
@@ -0,0 +1,4 @@
period,mainland,hkmtw,overseas,global,global_yoy
2024H1,2189,35,,,
2025H1,2396,41,160,2597,
2026H1,2436,46,345,2827,8.9
1 period mainland hkmtw overseas global global_yoy
2 2024H1 2189 35
3 2025H1 2396 41 160 2597
4 2026H1 2436 46 345 2827 8.9
+6
View File
@@ -25,6 +25,9 @@ import { cashflowCompositionChart } from './ppmt_cashflow_composition.js';
import { workingCapitalDaysChart } from './ppmt_working_capital_days.js';
import { expenseStructureChart } from './ppmt_expense_structure.js';
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';
/** 图表构建函数注册表。manifest 只引用这里的 key,不直接引用 JS 函数。 */
export const chartRegistry = {
@@ -55,6 +58,9 @@ export const chartRegistry = {
'ppmt-working-capital-days': workingCapitalDaysChart,
'ppmt-expense-structure': expenseStructureChart,
'ppmt-channel-revenue': channelRevenueChart,
'ppmt-stores-retail': storesRetailChart,
'ppmt-stores-robot': storesRobotChart,
'ppmt-region-share': regionShareChart,
};
export function getChartBuilder(key) {
+93 -9
View File
@@ -6,20 +6,77 @@ import {
} from '../theme.js';
/** 泡泡玛特渠道收入结构与增速。 */
const channels = [
{ key: 'retail', name: '零售店', color: CHANNEL_COLORS.retail },
{ key: 'online', name: '线上', color: CHANNEL_COLORS.online },
{ key: 'robot_store', name: '机器人商店', color: CHANNEL_COLORS.robotStore },
{ key: 'wholesale', name: '批发', color: CHANNEL_COLORS.wholesale },
];
// 段内标签文字色:浅色底(批发金)用深字,其余深色底用白字
const INSIDE_LABEL_COLORS = {
retail: '#fff',
online: '#fff',
robot_store: '#fff',
wholesale: PALETTE.ink,
};
const num = (value) => (Number.isFinite(value) ? value : null);
// 轴上限取整齐刻度,柱高比例确定后,段标签的段内/外置可按像素判断
const NICE_MAX = [20, 25, 50, 60, 80, 100, 120, 150, 200, 250, 300, 400, 500, 600, 800, 1000];
const MIN_INSIDE_PX = 13; // 低于约 13px 的段放不下 10px 段内标签
export function channelRevenueChart(rows, { width, height, view = 'all' }) {
const channels = [
{ key: 'retail', name: '零售店', color: CHANNEL_COLORS.retail },
{ key: 'online', name: '线上', color: CHANNEL_COLORS.online },
{ key: 'robot_store', name: '机器人商店', color: CHANNEL_COLORS.robotStore },
{ key: 'wholesale', name: '批发', color: CHANNEL_COLORS.wholesale },
];
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;
// 绘图区像素高:画布高 − grid top 145 − grid bottom 90
const pxPerUnit = (height - LAYOUT.gridTop - 90) / axisMax;
// 每根柱里放不下的矮段:标签外置到柱体右侧。同一柱内多个矮段标签
// 自下而上保持 12px 间距;整根柱都是矮段时,纵向扇形以柱身中部为中心,
// 避免标签整体飘离柱顶。返回 { 渠道序号: offset dy }(dy 为像素,正方向向下)。
const fanOutsideLabels = (row) => {
const values = channels.map((item) => num(row[item.key]));
const centers = [];
let cum = 0;
values.forEach((value) => {
centers.push((cum + value / 2) * pxPerUnit);
cum += value;
});
const barTopPx = cum * pxPerUnit;
const tinyIdx = channels
.map((_, i) => i)
.filter((i) => values[i] !== null && values[i] * pxPerUnit < MIN_INSIDE_PX);
if (tinyIdx.length === 0) return {};
const ys = tinyIdx.map((i) => centers[i]);
for (let k = 1; k < ys.length; k += 1) {
ys[k] = Math.max(ys[k], ys[k - 1] + 12);
}
if (tinyIdx.length === channels.length) {
const shift = barTopPx / 2 - (ys[0] + ys[ys.length - 1]) / 2;
for (let k = 0; k < ys.length; k += 1) ys[k] += shift;
if (ys[0] < 2) {
const lift = 2 - ys[0];
for (let k = 0; k < ys.length; k += 1) ys[k] += lift;
}
}
const offsets = {};
tinyIdx.forEach((i, k) => {
offsets[i] = centers[i] - ys[k];
});
return offsets;
};
const rowOffsets = rows.map(fanOutsideLabels);
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: `泡泡玛特渠道收入结构与增速(${financialViewLabel(view)})`,
subtext: ['渠道收入为堆叠柱;折线为营业收入同比增速', financialViewDisclosure(view)].filter(Boolean).join('\n'),
subtext: ['渠道收入为堆叠柱,柱内及柱侧标注各渠道数值;折线为营业收入同比增速', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
legend: {
...legendStyle,
@@ -43,6 +100,8 @@ export function channelRevenueChart(rows, { width, height, view = 'all' }) {
nameGap: 56,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
max: axisMax,
interval: axisMax % 4 === 0 ? axisMax / 4 : axisMax / 5,
},
{
...axisCommon,
@@ -60,12 +119,37 @@ export function channelRevenueChart(rows, { width, height, view = 'all' }) {
},
],
series: [
...channels.map((item) => ({
...channels.map((item, ci) => ({
name: item.name,
type: 'bar',
stack: 'channel-revenue',
data: col(rows, item.key),
data: rows.map((row, ri) => {
const value = num(row[item.key]);
if (value === null) return null;
const inside = value * pxPerUnit >= MIN_INSIDE_PX;
return {
value,
label: inside
? { position: 'inside', color: INSIDE_LABEL_COLORS[item.key] }
: {
position: 'right',
color: PALETTE.ink,
distance: 3,
offset: [0, rowOffsets[ri][ci] || 0],
textBorderColor: '#fff',
textBorderWidth: 2,
},
};
}),
itemStyle: { color: item.color },
label: {
show: true,
fontSize: 10,
fontWeight: 500,
fontFamily: FONT,
formatter: (p) => Number(p.value).toFixed(1),
overflow: 'none',
},
emphasis: { focus: 'series' },
})),
{
+6 -23
View File
@@ -1,7 +1,7 @@
import {
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, LEGEND_ITEM_WIDTH,
PALETTE, FONT, axisCommon, categoryAxis, titleStyle,
financialViewDisclosure, financialViewLabel, shortStandalonePeriod,
barSeries, footnote, watermarks, LAYOUT,
standardLegend, barSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/**
@@ -36,9 +36,6 @@ const COLORS = {
'外采及其他': '#8C8C8C',
};
const LEGEND_WIDTH = 160;
const LEGEND_RIGHT = 20;
export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) {
const periods = Object.keys(rows[0]).filter((k) => k !== 'ip');
const names = rows.map((r) => r.ip);
@@ -53,17 +50,8 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) {
text: `泡泡玛特各IP收入占比(${financialViewLabel(view)})`,
subtext: ['占当期IP收入合计的比重;H2为下半年单期,非全年累计', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
legend: {
orient: 'vertical',
right: LEGEND_RIGHT,
width: LEGEND_WIDTH,
top: 'middle',
itemGap: 12,
itemWidth: LEGEND_ITEM_WIDTH,
itemHeight: 14,
data: names,
textStyle: { color: PALETTE.ink, fontSize: 12, fontFamily: FONT },
},
// 11 个系列较多:紧凑间距靠 ECharts 自动换行成多行
legend: standardLegend(names, { itemGap: 12, itemWidth: 14, itemHeight: 14 }),
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
@@ -80,12 +68,7 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) {
.join('<br/>');
},
},
grid: {
left: 78,
right: LEGEND_RIGHT + LEGEND_WIDTH + 20,
top: LAYOUT.gridTop,
bottom: 70,
},
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 70 },
xAxis: {
...categoryAxis,
data: periods,
@@ -114,7 +97,7 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) {
fontFamily: FONT,
fontWeight: 500,
// 低于 4% 的段塞不进字,留空靠 tooltip 读数
formatter: (p) => (p.value >= 4 ? p.value.toFixed(1) : ''),
formatter: (p) => (p.value >= 4 ? `${p.value.toFixed(1)}%` : ''),
},
emphasis: { focus: 'series' },
})),
+111
View File
@@ -0,0 +1,111 @@
import {
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, standardLegend,
financialViewDisclosure, financialViewLabel, standalonePeriodAxis,
barSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/**
* 泡泡玛特分地区营收占比(2024H1 起)。
* 归一化堆叠柱状图:每根柱为当期营收合计 = 100%,看地区结构变化。
*
* 口径:中国含港澳台,海外 = 亚太 + 美洲 + 欧洲及其他(2024 年起的新披露口径)。
* H2 为单半年(当年披露全年 − 上半年还原),非全年累计,轴用 standalonePeriodAxis。
* 2024H1 的新口径分地区收入未直接披露,按 2025 年中报披露的分地区同比还原
* (2025H1 值 ÷ (1+同比)),还原后加总与当期总营收误差 < 0.1 亿。
*
* 地区配色与同 Tab 门店数量图(ppmt_stores_retail.js)保持一致。
*/
const REGIONS = [
{ key: 'china', name: '中国', color: '#3F7FBE' },
{ key: 'apac', name: '亚太', color: '#36A28F' },
{ key: 'americas', name: '美洲', color: '#E4A349' },
{ key: 'europe', name: '欧洲及其他', color: PALETTE.brown },
];
// 浅色底(美洲金)用深字,其余深色底用白字
const INSIDE_LABEL_COLORS = {
china: '#fff',
apac: '#fff',
americas: PALETTE.ink,
europe: '#fff',
};
// 绘图区像素高:画布高 − grid top 145 − grid bottom 100;轴固定 0-100%。
// 低于约 13px 的段放不下 11px 段内标签:是柱顶段的标到柱顶上方,
// 上方还有其它段的标到柱体右侧。
const MIN_INSIDE_PX = 13;
function segmentLabel(row, item, ri, totals, keyIndex, pxPerPct) {
const pct = ((row[item.key] || 0) / totals[ri]) * 100;
if (pct * pxPerPct >= MIN_INSIDE_PX) {
return { value: pct, label: { position: 'inside', color: INSIDE_LABEL_COLORS[item.key] } };
}
const hasSegmentAbove = REGIONS.slice(keyIndex + 1).some((r) => (row[r.key] || 0) > 0);
return {
value: pct,
label: hasSegmentAbove
? { position: 'right', color: PALETTE.ink, distance: 4, textBorderColor: '#fff', textBorderWidth: 2 }
: { position: 'top', color: PALETTE.ink, distance: 4 },
};
}
export function regionShareChart(rows, { width, height, view = 'all' }) {
const periods = rows.map((row) => row.period);
const totals = rows.map((row) => REGIONS.reduce((s, item) => s + (row[item.key] || 0), 0));
const pxPerPct = (height - LAYOUT.gridTop - 100) / 100;
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: `泡泡玛特分地区营收占比(${financialViewLabel(view)})`,
subtext: ['归一化堆叠,每根柱为当期营收合计=100%;H2为下半年单期', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
legend: standardLegend(REGIONS.map((item) => item.name)),
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
formatter: (ps) => {
const i = periods.indexOf(ps[0].axisValue);
const head = `<b>${ps[0].axisValue}</b> 合计 ${totals[i].toFixed(1)} 亿元<br/>`;
return head + ps
.slice()
.reverse()
.map((p) => `${p.marker}${p.seriesName}: ${p.value.toFixed(1)}%`
+ `(${((p.value / 100) * totals[i]).toFixed(1)} 亿元)`)
.join('<br/>');
},
},
grid: { left: 78, right: 82, 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, keyIndex) => barSeries({
name: item.name,
data: rows.map((row, ri) => segmentLabel(row, item, ri, totals, keyIndex, pxPerPct)),
color: item.color,
barWidth: 40,
stack: 'region-share',
label: {
show: true,
position: 'inside',
fontSize: 11,
fontFamily: FONT,
fontWeight: 500,
formatter: (p) => `${p.value.toFixed(1)}%`,
},
emphasis: { focus: 'series' },
})),
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特财报;中国含港澳台\n2024H1为按2025年中报披露的分地区同比还原;H2为单半年 | @思考的Joey'),
],
};
}
+134
View File
@@ -0,0 +1,134 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend,
financialViewLabel, lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/**
* 泡泡玛特分地区零售门店数量与同比增速。
* 堆叠柱为各期末门店数,折线为全球合计数量的同比。
* 口径:2023年及以前"海外合计"含港澳台;2024年起港澳台单列并计入中国,
* 海外合计 = 亚太 + 美洲 + 欧洲。两段口径不可直接跨 2023/2024 对比海外。
*/
// 门店数是计数而非金额,保留整数(CLAUDE.md 一位小数规则针对金额/比率)。
const SEGMENT_COLORS = {
mainland: '#3F7FBE',
hkmtw: '#AECBE8',
apac: '#36A28F',
americas: '#E4A349',
europe: PALETTE.brown,
overseas: '#C2726B',
};
// 段内标签文字色:浅色底(港澳台、美洲)用深字,深色底用白字
const INSIDE_LABEL_COLORS = {
mainland: '#fff',
hkmtw: PALETTE.ink,
apac: '#fff',
americas: PALETTE.ink,
europe: '#fff',
overseas: '#fff',
};
const regions = [
{ key: 'mainland', name: '中国内地' },
{ key: 'hkmtw', name: '港澳台' },
{ key: 'apac', name: '亚太' },
{ key: 'americas', name: '美洲' },
{ key: 'europe', name: '欧洲' },
{ key: 'overseas', name: '海外合计(含港澳台)' },
];
const num = (value) => (Number.isFinite(value) ? value : null);
// 2024 起披露口径变为港澳台单列 + 亚太/美洲/欧洲分列;此前只有含港澳台的海外合计。
// 同一根柱内两种披露结构互斥出现,堆叠总和始终等于全球合计。
function stackValue(row, key) {
const split = Number.isFinite(row.apac);
if (key === 'overseas') return split ? null : num(row.overseas);
if (!split && key !== 'mainland') return null;
return num(row[key]);
}
export function storesRetailChart(rows, { width, height, view = 'all' }) {
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: `泡泡玛特分地区零售门店数量与同比增速(${financialViewLabel(view)})`,
subtext: '柱状为各期末零售门店数(堆叠);折线为全球合计门店数量同比',
},
legend: standardLegend([...regions.map((item) => item.name), '全球合计同比']),
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 100 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
},
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,
max: 700,
interval: 100,
},
{
...axisCommon,
type: 'value',
name: '全球合计同比(%)',
nameLocation: 'middle',
nameGap: 60,
nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
max: 100,
interval: 20,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
},
],
series: [
...regions.map((item) => ({
name: item.name,
type: 'bar',
stack: 'stores-retail',
data: rows.map((row) => stackValue(row, item.key)),
itemStyle: { color: SEGMENT_COLORS[item.key] },
// 每段标注门店数:浅色段用深字、深色段用白字,保证段内可读
label: {
show: true,
position: 'inside',
fontSize: 10,
fontWeight: 500,
fontFamily: FONT,
color: INSIDE_LABEL_COLORS[item.key],
overflow: 'none',
},
// 相邻矮段(如 2024H1 美洲/欧洲)标签贴得近时沿 y 向微移避让
labelLayout: { moveOverlap: 'shiftY' },
emphasis: { focus: 'series' },
})),
{
...lineSeries({
name: '全球合计同比',
data: col(rows, 'global_yoy'),
color: '#8256B2',
formatter: (p) => `${p.value.toFixed(1)}%`,
}),
yAxisIndex: 1,
z: 10,
},
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特历年公告;门店数为各期末时点数\n2023年及以前海外合计含港澳台;2024年起港澳台单列,海外=亚太+美洲+欧洲 | @思考的Joey'),
],
};
}
+144
View File
@@ -0,0 +1,144 @@
import { col } from '../csv.js';
import {
CHANNEL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle,
standardLegend, financialViewLabel, lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/**
* 泡泡玛特分地区机器人商店数量与同比增速。
* 堆叠柱为各期末机器人数,折线为全球合计数量的同比。
* 口径:2023年及以前"海外合计"含港澳台;2024年起港澳台单列并计入中国。
* 机器人商店仅披露内地/港澳台/海外三层,无亚太/美洲/欧洲分列。
*/
// 门店数是计数而非金额,保留整数。中国内地沿用渠道语义色(机器人商店=绿)。
const SEGMENT_COLORS = {
mainland: CHANNEL_COLORS.robotStore,
hkmtw: '#AECBE8',
overseas: '#C2726B',
};
const regions = [
{ key: 'mainland', name: '中国内地' },
{ key: 'hkmtw', name: '港澳台' },
{ key: 'overseas', name: '海外合计(含港澳台)' },
];
// 段内标签文字色:浅色底(港澳台)用深字,深色底(内地绿、海外玫红)用白字
const INSIDE_LABEL_COLORS = {
mainland: '#fff',
hkmtw: PALETTE.ink,
overseas: '#fff',
};
const num = (value) => (Number.isFinite(value) ? value : null);
// 绘图区高约 435px(grid top 145 / bottom 100,画布 680),轴上限 3000。
// 低于约 13px 的段放不下段内 10px 标签:柱顶段标到柱顶上方,
// 上方还有其它段的标到柱体右侧,避免与上段段内标签互相挤压。
const PX_PER_UNIT = 435 / 3000;
const MIN_INSIDE_PX = 13;
function segmentData(row, key) {
const value = num(row[key]);
if (value === null) return null;
if (value * PX_PER_UNIT >= MIN_INSIDE_PX) {
return { value, label: { position: 'inside', color: INSIDE_LABEL_COLORS[key] } };
}
const keyIndex = regions.findIndex((item) => item.key === key);
const hasSegmentAbove = regions
.slice(keyIndex + 1)
.some((item) => num(row[item.key]) !== null);
return {
value,
label: hasSegmentAbove
? { position: 'right', color: PALETTE.ink, distance: 4 }
: { position: 'top', color: PALETTE.ink },
};
}
export function storesRobotChart(rows, { width, height, view = 'all' }) {
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: `泡泡玛特分地区机器人商店数量与同比增速(${financialViewLabel(view)})`,
subtext: '柱状为各期末机器人商店数(堆叠);折线为全球合计数量同比',
},
legend: standardLegend([...regions.map((item) => item.name), '全球合计同比']),
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 100 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
},
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,
max: 3000,
interval: 500,
},
{
...axisCommon,
type: 'value',
name: '全球合计同比(%)',
nameLocation: 'middle',
nameGap: 60,
nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
max: 600,
interval: 100,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
},
],
series: [
...regions.map((item) => ({
name: item.name,
type: 'bar',
stack: 'stores-robot',
data: rows.map((row) => segmentData(row, item.key)),
itemStyle: { color: SEGMENT_COLORS[item.key] },
label: {
show: true,
fontSize: 10,
fontWeight: 500,
fontFamily: FONT,
overflow: 'none',
},
// 相邻矮段标签贴近时沿 y 向微移避让
labelLayout: { moveOverlap: 'shiftY' },
emphasis: { focus: 'series' },
})),
{
...lineSeries({
name: '全球合计同比',
data: col(rows, 'global_yoy'),
color: '#8256B2',
formatter: (p) => `${p.value.toFixed(1)}%`,
}),
yAxisIndex: 1,
z: 10,
},
],
graphic: [
...watermarks(width, height),
footnote(
[
'数据来源:泡泡玛特历年公告;门店数为各期末时点数',
view === 'annual'
? '海外合计2023年及以前含港澳台、其后不含 | @思考的Joey'
: '海外合计2023年及以前含港澳台、其后不含;2024H1海外数量未披露 | @思考的Joey',
].join('\n'),
),
],
};
}
+3 -1
View File
@@ -176,7 +176,7 @@ export const legendStyle = {
},
};
/** 折线标注:带白底描边,避免压在网格线上看不清 */
/** 折线标注:白底描边,避免标签被折线自身或柱体穿过时看不清 */
export function labelStyle(color, position = 'top') {
return {
show: true,
@@ -186,6 +186,8 @@ export function labelStyle(color, position = 'top') {
fontFamily: FONT,
fontWeight: 500,
distance: 8,
textBorderColor: '#fff',
textBorderWidth: 3,
};
}
+5 -2
View File
@@ -25,7 +25,10 @@
"ppmt-cashflow-composition": { "key": "ppmt-cashflow-composition", "title": "三大现金流与现金净增加", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_cashflow_composition.csv", "annual": "data/ppmt/ppmt_cashflow_composition_annual.csv", "halfyear": "data/ppmt/ppmt_cashflow_composition_halfyear.csv" }, "height": 700, "filename": "泡泡玛特三大现金流与现金净增加" },
"ppmt-working-capital-days": { "key": "ppmt-working-capital-days", "title": "营运资本效率", "csv": "data/ppmt/ppmt_working_capital_days.csv", "height": 700, "filename": "泡泡玛特营运资本效率" },
"ppmt-expense-structure": { "key": "ppmt-expense-structure", "title": "费用结构与费用率", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_expense_structure.csv", "annual": "data/ppmt/ppmt_expense_structure_annual.csv", "halfyear": "data/ppmt/ppmt_expense_structure_halfyear.csv" }, "height": 700, "filename": "泡泡玛特费用结构与费用率" },
"ppmt-channel-revenue": { "key": "ppmt-channel-revenue", "title": "渠道收入结构与增速", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_channel_revenue.csv", "annual": "data/ppmt/ppmt_channel_revenue_annual.csv", "halfyear": "data/ppmt/ppmt_channel_revenue_halfyear.csv" }, "height": 720, "filename": "泡泡玛特渠道收入结构与增速" }
"ppmt-channel-revenue": { "key": "ppmt-channel-revenue", "title": "渠道收入结构与增速", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_channel_revenue.csv", "annual": "data/ppmt/ppmt_channel_revenue_annual.csv", "halfyear": "data/ppmt/ppmt_channel_revenue_halfyear.csv" }, "height": 720, "filename": "泡泡玛特渠道收入结构与增速" },
"ppmt-stores-retail": { "key": "ppmt-stores-retail", "title": "分地区零售门店数量与同比增速", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_stores_retail.csv", "annual": "data/ppmt/ppmt_stores_retail_annual.csv", "halfyear": "data/ppmt/ppmt_stores_retail_halfyear.csv" }, "height": 680, "filename": "泡泡玛特分地区零售门店数量与同比增速" },
"ppmt-stores-robot": { "key": "ppmt-stores-robot", "title": "分地区机器人商店数量与同比增速", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_stores_robot.csv", "annual": "data/ppmt/ppmt_stores_robot_annual.csv", "halfyear": "data/ppmt/ppmt_stores_robot_halfyear.csv" }, "height": 680, "filename": "泡泡玛特分地区机器人商店数量与同比增速" },
"ppmt-region-share": { "key": "ppmt-region-share", "title": "分地区营收占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_region_share.csv", "annual": "data/ppmt/ppmt_region_share_annual.csv", "halfyear": "data/ppmt/ppmt_region_share_halfyear.csv" }, "height": 680, "filename": "泡泡玛特分地区营收占比" }
},
"tabs": [
{ "id": "overview", "name": "概览", "chartRefs": ["ppmt-growth", "ppmt-profit", "ppmt-ip-share", "ppmt-region-channel"] },
@@ -33,7 +36,7 @@
{ "id": "expense", "name": "费用率", "chartRefs": ["ppmt-margins", "ppmt-expense-structure"] },
{ "id": "balance-sheet", "name": "资产负债", "chartRefs": ["ppmt-assets", "ppmt-liquidity-working-capital", "ppmt-working-capital-days"] },
{ "id": "ip-category", "name": "IP 与品类", "chartRefs": ["ppmt-ip", "ppmt-ip-share", "ppmt-ip-share-history", "ppmt-ip-ex-monsters", "ppmt-category"] },
{ "id": "region-channel", "name": "区域渠道", "chartRefs": ["ppmt-region-channel", "ppmt-region-yoy", "ppmt-region-yoy-timeseries", "ppmt-channel-revenue"] }
{ "id": "region-channel", "name": "区域渠道", "chartRefs": ["ppmt-region-channel", "ppmt-region-yoy", "ppmt-region-yoy-timeseries", "ppmt-channel-revenue", "ppmt-stores-retail", "ppmt-stores-robot", "ppmt-region-share"] }
]
},
{