feat: 优化泡泡玛特的图表

This commit is contained in:
R524809
2026-09-10 17:44:09 +08:00
parent a6387f8220
commit 417116b928
14 changed files with 327 additions and 299 deletions
-16
View File
@@ -1,16 +0,0 @@
region,channel,value
中国,线下渠道,68.69
中国,线上渠道,47.79
中国,批发及其他,5.53
海外,线下渠道,31.94
海外,线上渠道,14.34
海外,批发及其他,3.45
亚太,线下渠道,17.73
亚太,线上渠道,6.44
亚太,批发及其他,1.59
美洲,线下渠道,10.07
美洲,线上渠道,7.22
美洲,批发及其他,1.62
欧洲及其他,线下渠道,4.14
欧洲及其他,线上渠道,0.68
欧洲及其他,批发及其他,0.24
1 region channel value
2 中国 线下渠道 68.69
3 中国 线上渠道 47.79
4 中国 批发及其他 5.53
5 海外 线下渠道 31.94
6 海外 线上渠道 14.34
7 海外 批发及其他 3.45
8 亚太 线下渠道 17.73
9 亚太 线上渠道 6.44
10 亚太 批发及其他 1.59
11 美洲 线下渠道 10.07
12 美洲 线上渠道 7.22
13 美洲 批发及其他 1.62
14 欧洲及其他 线下渠道 4.14
15 欧洲及其他 线上渠道 0.68
16 欧洲及其他 批发及其他 0.24
-2
View File
@@ -8,7 +8,6 @@ import { profitChart } from './ppmt_profit.js';
import { ipChart } from './ppmt_ip.js'; import { ipChart } from './ppmt_ip.js';
import { categoryChart } from './ppmt_category.js'; import { categoryChart } from './ppmt_category.js';
import { sankeyChart } from './ppmt_sankey.js'; import { sankeyChart } from './ppmt_sankey.js';
import { regionChannelChart } from './ppmt_region_channel.js';
import { regionChannelYoyChart } from './ppmt_region_yoy.js'; import { regionChannelYoyChart } from './ppmt_region_yoy.js';
import { regionChannelYoyTimeseriesChart } from './ppmt_region_channel_yoy_timeseries.js'; import { regionChannelYoyTimeseriesChart } from './ppmt_region_channel_yoy_timeseries.js';
import { ppmtGrowthChart } from './ppmt_growth.js'; import { ppmtGrowthChart } from './ppmt_growth.js';
@@ -60,7 +59,6 @@ export const chartRegistry = {
'ppmt-ip': ipChart, 'ppmt-ip': ipChart,
'ppmt-category': categoryChart, 'ppmt-category': categoryChart,
'ppmt-sankey': sankeyChart, 'ppmt-sankey': sankeyChart,
'ppmt-region-channel': regionChannelChart,
'ppmt-region-yoy': regionChannelYoyChart, 'ppmt-region-yoy': regionChannelYoyChart,
'ppmt-region-yoy-timeseries': regionChannelYoyTimeseriesChart, 'ppmt-region-yoy-timeseries': regionChannelYoyTimeseriesChart,
'ppmt-growth': ppmtGrowthChart, 'ppmt-growth': ppmtGrowthChart,
+54 -35
View File
@@ -1,16 +1,45 @@
import { col } from '../csv.js'; import { col } from '../csv.js';
import { import {
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
financialViewLabel, financialViewLabel, niceAxisMax, fanTinyLabelOffsets, stackedSharePoint,
lineSeries, footnote, watermarks, LAYOUT, lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js'; } from '../theme.js';
const KEYS = ['cash_like', 'receivables', 'investment', 'operating_assets'];
const NAMES = {
cash_like: '类现金',
receivables: '应收账款',
investment: '投资资产',
operating_assets: '经营类资产',
};
const COLORS = {
cash_like: PALETTE.blue,
receivables: PALETTE.orange,
investment: PALETTE.purple,
operating_assets: PALETTE.sage,
};
// 段内标签文字色:浅色段(经营类资产灰绿)用深字,深色段用白字
const INSIDE_LABEL_COLORS = {
cash_like: '#fff',
receivables: '#fff',
investment: '#fff',
operating_assets: PALETTE.ink,
};
/** /**
* 泡泡玛特资产结构演变图 * 泡泡玛特资产结构演变图
* 堆叠柱状图展示资产配置,折线图展示总资产增长 * 堆叠柱状图展示资产配置(每段标数值),折线图展示总资产增长
*/ */
export function assetsChart(rows, { width, height, view = 'all' }) { export function assetsChart(rows, { width, height, view = 'all' }) {
const periods = col(rows, 'period'); const periods = col(rows, 'period');
const totalMax = Math.max(...rows.map((r) => r.total_assets));
// 轴上限随数据走(2025H2 总资产已 321 亿),矮段像素判定才有确定基准
const axisMax = niceAxisMax(totalMax);
const interval = axisMax % 7 === 0 ? axisMax / 7 : axisMax / 5;
// 绘图区像素高:画布高 − grid top 145 − grid bottom 90
const pxPerUnit = (height - LAYOUT.gridTop - 90) / axisMax;
// 早期年份应收/投资不足 1 亿,矮段标签外置到柱体右侧扇形排开
const rowOffsets = fanTinyLabelOffsets(rows, KEYS, pxPerUnit);
return { return {
backgroundColor: '#fff', backgroundColor: '#fff',
@@ -22,7 +51,7 @@ export function assetsChart(rows, { width, height, view = 'all' }) {
legend: { legend: {
...legendStyle, ...legendStyle,
top: LAYOUT.legendTop, top: LAYOUT.legendTop,
data: ['类现金', '应收账款', '投资资产', '经营类资产', '资产总计'], data: [...KEYS.map((key) => NAMES[key]), '资产总计'],
}, },
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 }, grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
tooltip: { tooltip: {
@@ -40,8 +69,8 @@ export function assetsChart(rows, { width, height, view = 'all' }) {
nameGap: 52, nameGap: 52,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0, min: 0,
max: 350, max: axisMax,
interval: 50, interval,
}, },
{ {
...axisCommon, ...axisCommon,
@@ -52,44 +81,34 @@ export function assetsChart(rows, { width, height, view = 'all' }) {
nameRotate: -90, nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0, min: 0,
max: 350, max: axisMax,
interval: 50, interval,
splitLine: { show: false }, splitLine: { show: false },
}, },
], ],
series: [ series: [
{ ...KEYS.map((key, ci) => ({
name: '类现金', name: NAMES[key],
type: 'bar', type: 'bar',
stack: 'assets', stack: 'assets',
data: col(rows, 'cash_like'), // 每段都标数值:矮段塞不进字的,外置到柱体右侧(机制同渠道金额图)
itemStyle: { color: PALETTE.blue }, data: rows.map((row, ri) => stackedSharePoint(row[key], {
emphasis: { focus: 'series' }, pxPerUnit,
}, labelColor: INSIDE_LABEL_COLORS[key],
{ dy: rowOffsets[ri][ci] ?? 0,
name: '应收账款', })),
type: 'bar', itemStyle: { color: COLORS[key] },
stack: 'assets',
data: col(rows, 'receivables'),
itemStyle: { color: PALETTE.orange },
emphasis: { focus: 'series' },
},
{
name: '投资资产',
type: 'bar',
stack: 'assets',
data: col(rows, 'investment'),
itemStyle: { color: PALETTE.purple },
emphasis: { focus: 'series' },
},
{
name: '经营类资产',
type: 'bar',
stack: 'assets',
data: col(rows, 'operating_assets'),
itemStyle: { color: PALETTE.sage },
emphasis: { focus: 'series' }, emphasis: { focus: 'series' },
label: {
show: true,
position: 'inside',
color: INSIDE_LABEL_COLORS[key],
fontSize: 11,
fontFamily: FONT,
overflow: 'none',
formatter: (p) => `${p.value.toFixed(1)}`,
}, },
})),
{ {
...lineSeries({ ...lineSeries({
name: '资产总计', name: '资产总计',
+122 -69
View File
@@ -1,49 +1,113 @@
import { col } from '../csv.js'; import { col } from '../csv.js';
import { import {
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
financialViewDisclosure, financialViewLabel, financialViewDisclosure, financialViewLabel, niceAxisMax,
footnote, watermarks, LAYOUT, fanTinyLabelOffsets, stackedSharePoint,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js'; } from '../theme.js';
/** /**
* 泡泡玛特品类收入结构图 * 泡泡玛特品类收入结构图
* 堆叠柱状图展示各品类的收入占比和增长 * 堆叠柱展示各品类收入;折线(右轴)展示各品类收入同比,折线与柱同色便于对应。
* 同比为衍生指标:当期 ÷ 上年同期 − 1(H2 对 H2 单半年、FY 对 FY 累计,同期对同期),
* 2024 年各期无上年基数,不计算。
*/ */
// 同比折线用柱色的同色系深色变体:压在柱体上能区分开,又靠色相保持与柱的对应
const CATEGORIES = [
{ name: '毛绒', key: 'plush', color: PALETTE.coral, lineColor: PALETTE.red },
{ name: '手办', key: 'figure', color: PALETTE.blue, lineColor: PALETTE.navy },
// 色板没有深紫,按 MEGA 紫加深
{ name: 'MEGA', key: 'mega', color: PALETTE.purple, lineColor: '#5E2A79' },
{ name: '衍生品及其他', key: 'other', color: PALETTE.sage, lineColor: PALETTE.green },
];
export function categoryChart(rows, { width, height, view = 'all' }) { export function categoryChart(rows, { width, height, view = 'all' }) {
// 过滤掉表头行("品类"那一行) // 过滤掉表头行("品类"那一行)
const dataRows = rows.filter(r => r.period && r.period !== '品类'); const dataRows = rows.filter(r => r.period && r.period !== '品类');
const periods = col(dataRows, 'period'); const periods = col(dataRows, 'period');
const num = (r, k) => (Number.isFinite(r[k]) ? r[k] : null);
// 上年同期的期间下标(H1↔H1、H2↔H2、FY↔FY),无则 -1
const priorIndex = periods.map((p) => {
if (!/^20\d{2}(H1|H2|FY)$/.test(p)) return -1;
const prev = p.replace(/^20(\d{2})/, (_, yy) => `20${String(Number(yy) - 1).padStart(2, '0')}`);
return periods.indexOf(prev);
});
const yoyByCat = Object.fromEntries(CATEGORIES.map((c) => [
c.key,
periods.map((_, i) => {
const j = priorIndex[i];
const cur = num(dataRows[i], c.key);
const prev = j >= 0 ? num(dataRows[j], c.key) : null;
if (cur == null || prev == null || prev === 0) return null;
return (cur / prev - 1) * 100;
}),
]));
const yoyValues = CATEGORIES.flatMap((c) => yoyByCat[c.key]).filter(Number.isFinite);
const yoyMax = Math.max(100, Math.ceil(Math.max(...yoyValues) / 100) * 100);
const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 100) * 100);
// 右轴固定整百刻度:范围 ≤500 时 100% 一步,否则 200% 一步,刻度数控制在 7 以内
const yoyInterval = yoyMax - yoyMin <= 500 ? 100 : 200;
// 多折线标签避让(同 ppmt_margins labeledData):比较上下间隙,朝更大的一侧放
const yoyLabelPos = periods.map((_, i) => {
const ranked = CATEGORIES
.map((c) => yoyByCat[c.key][i])
.filter(Number.isFinite)
.sort((a, b) => b - a);
return Object.fromEntries(CATEGORIES.map((c) => {
const v = yoyByCat[c.key][i];
if (!Number.isFinite(v)) return [c.key, 'top'];
const rank = ranked.indexOf(v);
const gapUp = rank > 0 ? ranked[rank - 1] - v : Infinity;
const gapDown = rank < ranked.length - 1 ? v - ranked[rank + 1] : Infinity;
return [c.key, gapUp >= gapDown ? 'top' : 'bottom'];
}));
});
const totals = periods.map((_, i) => CATEGORIES.reduce((s, c) => s + (num(dataRows[i], c.key) ?? 0), 0));
// 轴上限随数据走:2025FY 合计已 371.2 亿,写死 250 会把柱子顶出绘图区
const axisMax = niceAxisMax(Math.max(...totals));
const amountInterval = axisMax % 4 === 0 ? axisMax / 4 : axisMax / 5;
// 绘图区像素高:画布高 − grid top 145 − grid bottom 90
const pxPerUnit = (height - LAYOUT.gridTop - 90) / axisMax;
const rowOffsets = fanTinyLabelOffsets(dataRows, CATEGORIES.map((c) => c.key), pxPerUnit);
return { return {
backgroundColor: '#fff', backgroundColor: '#fff',
title: { title: {
...titleStyle, ...titleStyle,
text: `泡泡玛特品类收入结构(${financialViewLabel(view)})`, text: `泡泡玛特品类收入结构(${financialViewLabel(view)})`,
subtext: ['单位:亿元;H2为下半年单期', financialViewDisclosure(view)].filter(Boolean).join('\n'), subtext: ['柱为收入(亿元,左轴);折线为各品类收入同比(右轴);H2为下半年单期',
financialViewDisclosure(view)].filter(Boolean).join('\n'),
}, },
// 图例柱与同比线交错排列,同品类的两项相邻
legend: { legend: {
...legendStyle, ...legendStyle,
top: LAYOUT.legendTop, top: LAYOUT.legendTop,
data: ['毛绒', '手办', 'MEGA', '衍生品及其他'], data: CATEGORIES.flatMap((c) => [c.name, `${c.name}同比`]),
}, },
grid: { left: 78, right: 56, top: LAYOUT.gridTop, bottom: 90 }, grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'shadow' }, axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT }, textStyle: { fontFamily: FONT },
formatter: (params) => { formatter: (params) => {
const bars = params.filter((p) => p.seriesType === 'bar' && Number.isFinite(p.value));
const lines = params.filter((p) => p.seriesType === 'line' && Number.isFinite(p.value));
const total = bars.reduce((s, p) => s + p.value, 0);
let result = `<b>${params[0].axisValue}</b><br/>`; let result = `<b>${params[0].axisValue}</b><br/>`;
let total = 0; bars.forEach((p) => { result += `${p.marker}${p.seriesName}: ${p.value.toFixed(2)} 亿元<br/>`; });
params.forEach(p => { result += `<b>合计: ${total.toFixed(2)} 亿元</b><br/>`;
total += p.value; lines.forEach((p) => {
result += `${p.marker}${p.seriesName}: ${p.value.toFixed(2)} 亿元<br/>`; result += `${p.marker}${p.seriesName}: ${p.value > 0 ? '+' : ''}${p.value.toFixed(1)}%<br/>`;
}); });
result += `<b>合计: ${total.toFixed(2)} 亿元</b>`;
return result; return result;
}, },
}, },
xAxis: { ...standalonePeriodAxis, data: periods }, xAxis: { ...standalonePeriodAxis, data: periods },
yAxis: { yAxis: [
{
...axisCommon, ...axisCommon,
type: 'value', type: 'value',
name: '收入(亿元)', name: '收入(亿元)',
@@ -51,16 +115,36 @@ export function categoryChart(rows, { width, height, view = 'all' }) {
nameGap: 52, nameGap: 52,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0, min: 0,
max: 250, max: axisMax,
interval: 50, interval: amountInterval,
}, },
{
...axisCommon,
type: 'value',
name: '收入同比(%)',
nameLocation: 'middle',
nameGap: 56,
nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: yoyMin,
max: yoyMax,
interval: yoyInterval,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
},
],
series: [ series: [
{ ...CATEGORIES.map((c, ci) => ({
name: '毛绒', name: c.name,
type: 'bar', type: 'bar',
stack: 'category', stack: 'category',
data: col(dataRows, 'plush'), // 每段都标数值:矮段外置到柱体右侧,机制同渠道金额图
itemStyle: { color: PALETTE.coral }, data: dataRows.map((row, ri) => stackedSharePoint(row[c.key], {
pxPerUnit,
labelColor: '#fff',
dy: rowOffsets[ri][ci] ?? 0,
})),
itemStyle: { color: c.color },
emphasis: { focus: 'series' }, emphasis: { focus: 'series' },
label: { label: {
show: true, show: true,
@@ -68,61 +152,30 @@ export function categoryChart(rows, { width, height, view = 'all' }) {
color: '#fff', color: '#fff',
fontSize: 12, fontSize: 12,
fontFamily: FONT, fontFamily: FONT,
formatter: (p) => (p.value > 15 ? p.value.toFixed(1) : ''), overflow: 'none',
}, formatter: (p) => `${p.value.toFixed(1)}`,
},
{
name: '手办',
type: 'bar',
stack: 'category',
data: col(dataRows, 'figure'),
itemStyle: { color: PALETTE.blue },
emphasis: { focus: 'series' },
label: {
show: true,
position: 'inside',
color: '#fff',
fontSize: 12,
fontFamily: FONT,
formatter: (p) => (p.value > 15 ? p.value.toFixed(1) : ''),
},
},
{
name: 'MEGA',
type: 'bar',
stack: 'category',
data: col(dataRows, 'mega'),
itemStyle: { color: PALETTE.purple },
emphasis: { focus: 'series' },
label: {
show: true,
position: 'inside',
color: '#fff',
fontSize: 12,
fontFamily: FONT,
formatter: (p) => (p.value > 8 ? p.value.toFixed(1) : ''),
},
},
{
name: '衍生品及其他',
type: 'bar',
stack: 'category',
data: col(dataRows, 'other'),
itemStyle: { color: PALETTE.sage },
emphasis: { focus: 'series' },
label: {
show: true,
position: 'inside',
color: '#fff',
fontSize: 12,
fontFamily: FONT,
formatter: (p) => (p.value > 8 ? p.value.toFixed(1) : ''),
},
}, },
})),
...CATEGORIES.map((c) => ({
...lineSeries({
name: `${c.name}同比`,
data: yoyByCat[c.key],
color: c.lineColor,
formatter: (p) => `${p.value.toFixed(1)}%`,
width: 2.5,
}),
yAxisIndex: 1,
z: 10,
// label.position 不接受函数,逐点覆盖(避让规则见 yoyLabelPos)
data: yoyByCat[c.key].map((v, i) => (Number.isFinite(v)
? { value: v, label: { position: yoyLabelPos[i][c.key], distance: 10 } }
: null)),
labelLayout: { hideOverlap: true },
})),
], ],
graphic: [ graphic: [
...watermarks(width, height), ...watermarks(width, height),
footnote('数据来源:泡泡玛特财报 | @Joey知行记'), footnote('数据来源:泡泡玛特财报;同比=当期÷上年同期−1\n2024年各期无上年基数,不计算同比 | @Joey知行记'),
], ],
}; };
} }
+3 -5
View File
@@ -1,7 +1,7 @@
import { col } from '../csv.js'; import { col } from '../csv.js';
import { import {
CHANNEL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle, CHANNEL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
financialViewDisclosure, financialViewLabel, financialViewDisclosure, financialViewLabel, niceAxisMax,
lineSeries, footnote, watermarks, LAYOUT, lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js'; } from '../theme.js';
@@ -23,14 +23,12 @@ const INSIDE_LABEL_COLORS = {
const num = (value) => (Number.isFinite(value) ? value : null); 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 段内标签 const MIN_INSIDE_PX = 13; // 低于约 13px 的段放不下 10px 段内标签
export function channelRevenueChart(rows, { width, height, view = 'all' }) { export function channelRevenueChart(rows, { width, height, view = 'all' }) {
const totalMax = Math.max(...rows.map((row) => num(row.total_revenue) || 0)); const totalMax = Math.max(...rows.map((row) => num(row.total_revenue) || 0));
const axisMax = NICE_MAX.find((max) => max >= totalMax * 1.02) const axisMax = niceAxisMax(totalMax);
?? Math.ceil((totalMax * 1.2) / 100) * 100;
const yoyValues = col(rows, 'revenue_yoy').filter(Number.isFinite); const yoyValues = col(rows, 'revenue_yoy').filter(Number.isFinite);
// 全部/半年视图存在 2022H2 的负增长,右轴需包含负区间,避免折线被 0 轴裁切。 // 全部/半年视图存在 2022H2 的负增长,右轴需包含负区间,避免折线被 0 轴裁切。
const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 50) * 50); const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 50) * 50);
+69 -4
View File
@@ -1,12 +1,49 @@
import { col } from '../csv.js'; import { col } from '../csv.js';
import { import {
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
financialViewDisclosure, financialViewLabel, financialViewDisclosure, financialViewLabel, niceAxisMax, stackedSharePoint,
lineSeries, footnote, watermarks, LAYOUT, lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js'; } from '../theme.js';
/**
* 混合正负堆叠的矮段标签外置偏移。
* ECharts 对负值段自 0 向下堆叠,theme 的 fanTinyLabelOffsets 按全正累计
* 算不出正确中心,这里按符号分两摞累计(屏幕坐标 y 向下为正),
* 矮段(塞不进段内文字的)标签扇形排开在柱体右侧。
*/
function expenseFanOffsets(rows, keys, pxPerUnit, { minInsidePx = 15, gap = 13 } = {}) {
return rows.map((row) => {
let posCum = 0;
let negCum = 0;
const centers = {};
keys.forEach((k) => {
const v = Number.isFinite(row[k]) ? row[k] : 0;
const mid = v >= 0 ? posCum + v / 2 : negCum + v / 2;
centers[k] = -mid * pxPerUnit;
if (v >= 0) posCum += v; else negCum += v;
});
const tiny = keys.filter((k) => Number.isFinite(row[k]) && row[k] !== 0
&& Math.abs(row[k]) * pxPerUnit < minInsidePx);
if (!tiny.length) return {};
const ys = tiny.map((k) => centers[k]);
for (let i = 1; i < ys.length; i += 1) ys[i] = Math.max(ys[i], ys[i - 1] + gap);
const offsets = {};
tiny.forEach((k, i) => { offsets[k] = centers[k] - ys[i]; });
return offsets;
});
}
/** 泡泡玛特期间费用结构:观察销售投入、管理成本与费用率的规模效应。 */ /** 泡泡玛特期间费用结构:观察销售投入、管理成本与费用率的规模效应。 */
export function expenseStructureChart(rows, { width, height, view = 'all' }) { export function expenseStructureChart(rows, { width, height, view = 'all' }) {
const keys = ['sales_expense', 'admin_expense', 'financial_expense'];
const posStackMax = Math.max(...rows.map((r) => keys.reduce((s, k) => s + Math.max(r[k] ?? 0, 0), 0)));
const negMin = Math.min(...rows.map((r) => keys.reduce((s, k) => s + Math.min(r[k] ?? 0, 0), 0)));
// 轴范围随数据走并取整刻度,保证矮段像素判定(pxPerUnit)有确定基准
const axisMax = niceAxisMax(posStackMax);
const axisMin = Math.min(0, Math.floor(negMin / 10) * 10);
const pxPerUnit = (height - LAYOUT.gridTop - 90) / (axisMax - axisMin);
const rowOffsets = expenseFanOffsets(rows, keys, pxPerUnit);
return { return {
backgroundColor: '#fff', backgroundColor: '#fff',
title: { title: {
@@ -35,6 +72,9 @@ export function expenseStructureChart(rows, { width, height, view = 'all' }) {
nameLocation: 'middle', nameLocation: 'middle',
nameGap: 52, nameGap: 52,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: axisMin,
max: axisMax,
interval: 10,
}, },
{ {
...axisCommon, ...axisCommon,
@@ -52,9 +92,34 @@ export function expenseStructureChart(rows, { width, height, view = 'all' }) {
}, },
], ],
series: [ series: [
{ name: '销售费用', type: 'bar', stack: 'expenses', data: col(rows, 'sales_expense'), itemStyle: { color: PALETTE.orange } }, ...keys.map((key, ci) => ({
{ name: '管理费用', type: 'bar', stack: 'expenses', data: col(rows, 'admin_expense'), itemStyle: { color: PALETTE.purple } }, name: { sales_expense: '销售费用', admin_expense: '管理费用', financial_expense: '财务费用(净额)' }[key],
{ name: '财务费用(净额)', type: 'bar', stack: 'expenses', data: col(rows, 'financial_expense'), itemStyle: { color: PALETTE.blue } }, type: 'bar',
stack: 'expenses',
// 每段都标数值:矮段(含负值段)外置到柱体右侧,见 expenseFanOffsets
data: rows.map((row, ri) => stackedSharePoint(row[key], {
pxPerUnit,
labelColor: '#fff',
dy: rowOffsets[ri][key] ?? 0,
})),
itemStyle: { color: [PALETTE.orange, PALETTE.purple, PALETTE.blue][ci] },
emphasis: { focus: 'series' },
label: {
show: true,
position: 'inside',
color: '#fff',
fontSize: 11,
fontFamily: FONT,
fontWeight: 500,
overflow: 'none',
// 财务费用净额多在 ±0.1 亿内,一位小数会显示成 ±0.0,此时退到两位小数
formatter: (p) => {
const t = p.value.toFixed(1);
if (t === '0.0' || t === '-0.0') return Math.abs(p.value) < 0.005 ? '0.0' : p.value.toFixed(2);
return t;
},
},
})),
{ {
...lineSeries({ ...lineSeries({
name: '四项费用率', name: '四项费用率',
+7 -3
View File
@@ -1,7 +1,7 @@
import { col } from '../csv.js'; import { col } from '../csv.js';
import { import {
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, legendStyle, PALETTE, FONT, axisCommon, categoryAxis, titleStyle, legendStyle,
financialViewDisclosure, financialViewLabel, shortStandalonePeriod, financialViewDisclosure, financialViewLabel, shortStandalonePeriod, niceAxisMax,
lineSeries, footnote, watermarks, LAYOUT, lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js'; } from '../theme.js';
@@ -39,6 +39,10 @@ export function ipChart(rows, { width, height, view = 'all' }) {
}); });
}); });
// 轴上限随数据走(THE MONSTERS 2025 已超 140 亿),写死会把爆款折线顶出绘图区
const values = rows.flatMap((row) => periods.map((p) => row[p])).filter(Number.isFinite);
const axisMax = niceAxisMax(Math.max(...values));
return { return {
backgroundColor: '#fff', backgroundColor: '#fff',
title: { title: {
@@ -74,8 +78,8 @@ export function ipChart(rows, { width, height, view = 'all' }) {
nameGap: 52, nameGap: 52,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0, min: 0,
max: 100, max: axisMax,
interval: 20, interval: axisMax % 4 === 0 ? axisMax / 4 : axisMax / 5,
}, },
series, series,
graphic: [ graphic: [
+16 -6
View File
@@ -5,7 +5,7 @@ import {
/** /**
* 泡泡玛特主要 IP 收入占比(2026H1) * 泡泡玛特主要 IP 收入占比(2026H1)
* 饼图(非环图),图例放在右侧:每项上行 IP 名、下行小字显示收入和占比。 * 饼图(非环图),图例放在右侧:每项上行 IP 名、下行小字显示收入和占比。
* 扇区外直接标注 IP 名 + 数值(两行)。源:ppmt.xlsx「IP收入和占比」2026H1 列, * 扇区外直接标注 IP 名 + 数值/占比(两行),IP 名与扇区同色。源:ppmt.xlsx「IP收入和占比」2026H1 列,
* HACIPUPU、BUNNY、PUCKY 等该期无数据的 IP 不列入。 * HACIPUPU、BUNNY、PUCKY 等该期无数据的 IP 不列入。
* 占比为衍生指标:各 IP 收入 ÷ 全部列入 IP 收入合计(源表占比列未填)。 * 占比为衍生指标:各 IP 收入 ÷ 全部列入 IP 收入合计(源表占比列未填)。
* *
@@ -29,18 +29,28 @@ const COLORS = [
]; ];
export function ppmtIpShareChart(rows, { width, height }) { export function ppmtIpShareChart(rows, { width, height }) {
const data = rows.map((row, i) => ({ const data = rows.map((row, i) => {
const color = COLORS[i % COLORS.length];
return {
name: row.ip, name: row.ip,
value: row.revenue, value: row.revenue,
itemStyle: { color: COLORS[i % COLORS.length] }, itemStyle: { color },
})); // 逐扇区覆盖标签 rich:IP 名与所在区块同色,其余样式沿用系列级配置
label: {
rich: {
name: { fontFamily: FONT, fontSize: 12, fontWeight: 600, color, lineHeight: 16 },
value: { fontFamily: FONT, fontSize: 11, color: PALETTE.grey, lineHeight: 15 },
},
},
};
});
const total = data.reduce((s, d) => s + d.value, 0); const total = data.reduce((s, d) => s + d.value, 0);
const byName = Object.fromEntries(data.map((d) => [d.name, d])); const byName = Object.fromEntries(data.map((d) => [d.name, d]));
// 把「饼图 + 两侧外标签」当成一个整体,与图例并成一组居中摆放: // 把「饼图 + 两侧外标签」当成一个整体,与图例并成一组居中摆放:
// 图例紧跟在右侧标签外缘之后,不会被推到画布边缘留下大片空白 // 图例紧跟在右侧标签外缘之后,不会被推到画布边缘留下大片空白
const LEGEND_WIDTH = 152; // 最宽一行「17.7 亿元 · 10.3%」约 120px + 色块 const LEGEND_WIDTH = 152; // 最宽一行「17.7 亿元 · 10.3%」约 120px + 色块
const LEGEND_GAP = 24; // 右侧标签外缘 → 图例 const LEGEND_GAP = 56; // 右侧标签外缘 → 图例;最长数值行会侵入该间隙,实测 24px 太挤
// 左右标签占位不对称:左侧文字向左延伸(text-anchor: end)要占满, // 左右标签占位不对称:左侧文字向左延伸(text-anchor: end)要占满,
// 右侧只需容下引导线 + 首行文字起段,留太多会在图例前空出一大块 // 右侧只需容下引导线 + 首行文字起段,留太多会在图例前空出一大块
const LABEL_LEFT = 135; const LABEL_LEFT = 135;
@@ -105,7 +115,7 @@ export function ppmtIpShareChart(rows, { width, height }) {
data, data,
label: { label: {
show: true, show: true,
formatter: (p) => `{name|${p.name}}\n{value|${p.value.toFixed(1)} 亿元}`, formatter: (p) => `{name|${p.name}}\n{value|${p.value.toFixed(1)} 亿元 / ${p.percent.toFixed(1)}%}`,
rich: { rich: {
name: { fontFamily: FONT, fontSize: 12, color: PALETTE.ink, fontWeight: 600, lineHeight: 16 }, name: { fontFamily: FONT, fontSize: 12, color: PALETTE.ink, fontWeight: 600, lineHeight: 16 },
value: { fontFamily: FONT, fontSize: 11, color: PALETTE.grey, lineHeight: 15 }, value: { fontFamily: FONT, fontSize: 11, color: PALETTE.grey, lineHeight: 15 },
+18 -7
View File
@@ -1,7 +1,7 @@
import { import {
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, PALETTE, FONT, axisCommon, categoryAxis, titleStyle,
financialViewDisclosure, financialViewLabel, shortStandalonePeriod, financialViewDisclosure, financialViewLabel, shortStandalonePeriod,
standardLegend, barSeries, footnote, watermarks, LAYOUT, standardLegend, barSeries, fanTinyLabelOffsets, stackedSharePoint, footnote, watermarks, LAYOUT,
} from '../theme.js'; } from '../theme.js';
/** /**
@@ -40,8 +40,14 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) {
const periods = Object.keys(rows[0]).filter((k) => k !== 'ip'); const periods = Object.keys(rows[0]).filter((k) => k !== 'ip');
const names = rows.map((r) => r.ip); const names = rows.map((r) => r.ip);
const totals = periods.map((p) => rows.reduce((s, r) => s + r[p], 0)); const totals = periods.map((p) => rows.reduce((s, r) => s + r[p], 0));
// 归一化:各 IP 占当期合计的比重,逐期加总恰为 100% // 归一化:各 IP 占当期合计的比重,逐期加总恰为 100%。
const toPct = (r) => periods.map((p, i) => (r[p] / totals[i]) * 100); // 转成「期间 × 系列」的行结构,供 fanTinyLabelOffsets 逐柱计算矮段偏移。
const pctRows = periods.map((p, i) => Object.fromEntries(
rows.map((r) => [r.ip, (r[p] / totals[i]) * 100]),
));
// 绘图区像素高:画布高 − grid top 145 − grid bottom 70;轴固定 0–100%
const pxPerUnit = (height - LAYOUT.gridTop - 70) / 100;
const rowOffsets = fanTinyLabelOffsets(pctRows, names, pxPerUnit);
return { return {
backgroundColor: '#fff', backgroundColor: '#fff',
@@ -83,9 +89,14 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) {
interval: 20, interval: 20,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' }, axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
}, },
series: rows.map((r) => barSeries({ // 每个非零占比都要展示:塞得进段内的段内白字,矮段外置到柱体右侧(深字白描边)
series: rows.map((r, ci) => barSeries({
name: r.ip, name: r.ip,
data: toPct(r), data: pctRows.map((pr, ri) => stackedSharePoint(pr[r.ip], {
pxPerUnit,
labelColor: '#fff',
dy: rowOffsets[ri][ci] ?? 0,
})),
color: COLORS[r.ip] ?? PALETTE.grey, color: COLORS[r.ip] ?? PALETTE.grey,
barWidth: 40, barWidth: 40,
stack: 'share', stack: 'share',
@@ -96,8 +107,8 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) {
fontSize: 11, fontSize: 11,
fontFamily: FONT, fontFamily: FONT,
fontWeight: 500, fontWeight: 500,
// 低于 4% 的段塞不进字,留空靠 tooltip 读数 overflow: 'none',
formatter: (p) => (p.value >= 4 ? `${p.value.toFixed(1)}%` : ''), formatter: (p) => `${p.value.toFixed(1)}%`,
}, },
emphasis: { focus: 'series' }, emphasis: { focus: 'series' },
})), })),
-126
View File
@@ -1,126 +0,0 @@
import {
PALETTE, FONT, titleStyle, footnote, watermarks, legendStyle,
LAYOUT, } from '../theme.js';
/**
* 泡泡玛特区域渠道分布图(2026H1)
* 使用交错正负轴展示不同区域和渠道的收入
*/
export function regionChannelChart(rows, { width, height }) {
// 数据结构重组:按区域分组
const regions = ['中国', '海外', '亚太', '美洲', '欧洲及其他'];
const channels = ['线下渠道', '线上渠道', '批发及其他'];
// 构建数据映射
const dataMap = {};
rows.forEach(row => {
const key = `${row.region}_${row.channel}`;
dataMap[key] = row.value;
});
// 为每个渠道构建系列数据
const seriesData = channels.map(channel => {
return regions.map(region => {
const key = `${region}_${channel}`;
const value = dataMap[key] || 0;
// 线上渠道显示为负值(左侧),其他为正值(右侧)
return channel === '线上渠道' ? -value : value;
});
});
// 配色
const channelColors = {
'线下渠道': PALETTE.navy,
'线上渠道': PALETTE.coral,
'批发及其他': PALETTE.sage,
};
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特区域渠道分布(2026H1)',
subtext: '左侧:线上渠道 | 右侧:线下渠道、批发及其他,单位:亿元',
top: 18,
},
legend: {
...legendStyle,
data: channels,
top: LAYOUT.legendTop,
itemGap: 32,
textStyle: { fontFamily: FONT, fontSize: 13 },
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
formatter: (params) => {
let result = `<b>${params[0].axisValue}</b><br/>`;
params.forEach(p => {
const value = Math.abs(p.value);
result += `${p.marker}${p.seriesName}: ${value.toFixed(2)} 亿元<br/>`;
});
return result;
},
},
grid: {
left: 100,
right: 100,
top: 145,
bottom: 90,
containLabel: false,
},
xAxis: {
type: 'value',
position: 'top',
splitLine: {
show: true,
lineStyle: { color: '#e8e8e8', width: 1 }
},
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
formatter: (value) => Math.abs(value),
fontFamily: FONT,
fontSize: 12,
color: '#8c8c8c',
},
},
yAxis: {
type: 'category',
data: regions,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
fontFamily: FONT,
fontSize: 13,
color: '#333',
fontWeight: '500',
},
inverse: false,
},
series: channels.map((channel, idx) => ({
name: channel,
type: 'bar',
stack: channel === '线上渠道' ? 'left' : 'right',
data: seriesData[idx],
itemStyle: { color: channelColors[channel] },
label: {
show: true,
position: channel === '线上渠道' ? 'left' : 'right',
formatter: (params) => {
const value = Math.abs(params.value);
return value > 0 ? value.toFixed(1) : '';
},
fontFamily: FONT,
fontSize: 11,
color: '#333',
},
barWidth: 22,
})),
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特财报 | @Joey知行记'),
],
};
}
+9 -7
View File
@@ -4,11 +4,13 @@ import {
financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT, financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js'; } from '../theme.js';
// 同比虚线用柱色的同色系深色变体:压在柱体和彼此之间都能区分,色相仍与柱对应
const REGIONS = [ const REGIONS = [
{ key: 'china', name: '中国(含港澳台)', color: '#3F7FBE' }, { key: 'china', name: '中国(含港澳台)', color: '#3F7FBE', lineColor: PALETTE.navy },
{ key: 'apac', name: '亚太', color: '#36A28F' }, // 亚太青、美洲金、深棕在色板无对应深色,按柱色加深或取邻近深色
{ key: 'americas', name: '美洲', color: '#E4A349' }, { key: 'apac', name: '亚太', color: '#36A28F', lineColor: '#1F6E5F' },
{ key: 'europe_other', name: '欧洲及其他', color: PALETTE.brown }, { key: 'americas', name: '美洲', color: '#E4A349', lineColor: PALETTE.orange },
{ key: 'europe_other', name: '欧洲及其他', color: PALETTE.brown, lineColor: '#4A3117' },
]; ];
function yoyByPeriod(rows, key) { function yoyByPeriod(rows, key) {
@@ -37,7 +39,7 @@ export function retailStoreRevenueChart(rows, { width, height, view = 'all' }) {
title: { title: {
...titleStyle, ...titleStyle,
text: `泡泡玛特各地区零售店单店收入(${financialViewLabel(view)})`, text: `泡泡玛特各地区零售店单店收入(${financialViewLabel(view)})`,
subtext: '柱状为单店收入;同色虚线为单店收入同比;H2为下半年单期', subtext: '柱状为单店收入;深色虚线为对应柱的单店收入同比;H2为下半年单期',
}, },
legend: standardLegend(legendItems, { itemGap: 18 }), legend: standardLegend(legendItems, { itemGap: 18 }),
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 }, grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
@@ -93,12 +95,12 @@ export function retailStoreRevenueChart(rows, { width, height, view = 'all' }) {
...lineSeries({ ...lineSeries({
name: `${region.name}同比`, name: `${region.name}同比`,
data: region.data, data: region.data,
color: region.color, color: region.lineColor,
width: 2, width: 2,
formatter: (point) => `${point.value.toFixed(0)}%`, formatter: (point) => `${point.value.toFixed(0)}%`,
}), }),
yAxisIndex: 1, yAxisIndex: 1,
lineStyle: { color: region.color, width: 2, type: 'dashed' }, lineStyle: { color: region.lineColor, width: 2, type: 'dashed' },
z: 10, z: 10,
})), })),
], ],
+7 -5
View File
@@ -4,9 +4,11 @@ import {
financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT, financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js'; } from '../theme.js';
// 同比虚线用柱色的同色系深色变体:压在柱体和彼此之间都能区分,色相仍与柱对应
const REGIONS = [ const REGIONS = [
{ key: 'china', name: '中国(含港澳台)', color: '#71AD81' }, { key: 'china', name: '中国(含港澳台)', color: '#71AD81', lineColor: PALETTE.green },
{ key: 'overseas', name: '海外', color: PALETTE.brown }, // 色板没有深棕,按 brown 加深
{ key: 'overseas', name: '海外', color: PALETTE.brown, lineColor: '#4A3117' },
]; ];
function yoyByPeriod(rows, key) { function yoyByPeriod(rows, key) {
@@ -35,7 +37,7 @@ export function robotStoreRevenueChart(rows, { width, height, view = 'all' }) {
title: { title: {
...titleStyle, ...titleStyle,
text: `泡泡玛特机器人商店单店收入(${financialViewLabel(view)})`, text: `泡泡玛特机器人商店单店收入(${financialViewLabel(view)})`,
subtext: '柱状为单店收入;同色虚线为单店收入同比;H2为下半年单期', subtext: '柱状为单店收入;深色虚线为对应柱的单店收入同比;H2为下半年单期',
}, },
legend: standardLegend(legendItems, { itemGap: 18 }), legend: standardLegend(legendItems, { itemGap: 18 }),
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 }, grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
@@ -91,12 +93,12 @@ export function robotStoreRevenueChart(rows, { width, height, view = 'all' }) {
...lineSeries({ ...lineSeries({
name: `${region.name}同比`, name: `${region.name}同比`,
data: region.data, data: region.data,
color: region.color, color: region.lineColor,
width: 2, width: 2,
formatter: (point) => `${point.value.toFixed(0)}%`, formatter: (point) => `${point.value.toFixed(0)}%`,
}), }),
yAxisIndex: 1, yAxisIndex: 1,
lineStyle: { color: region.color, width: 2, type: 'dashed' }, lineStyle: { color: region.lineColor, width: 2, type: 'dashed' },
z: 10, z: 10,
})), })),
], ],
+9
View File
@@ -161,6 +161,15 @@ export const LAYOUT = {
gridTop: 145, // 绘图区顶部边距(图例到绘图区约 60px) gridTop: 145, // 绘图区顶部边距(图例到绘图区约 60px)
}; };
/** 数值轴整齐上限候选:轴 max 从中取第一个能容纳数据的刻度 */
export const NICE_AXIS_MAX = [20, 25, 50, 60, 80, 100, 120, 150, 200, 250, 300, 400, 500, 600, 800, 1000];
/** 按数据最大值取整齐轴上限;超出候选表尾时按 100 的整数倍向上取整并留 20% 余量 */
export function niceAxisMax(dataMax) {
return NICE_AXIS_MAX.find((max) => max >= dataMax * 1.02)
?? Math.ceil((dataMax * 1.2) / 100) * 100;
}
// 统一图例图标宽度,同时保留 ECharts 按系列类型生成的形状。 // 统一图例图标宽度,同时保留 ECharts 按系列类型生成的形状。
export const LEGEND_ITEM_WIDTH = 20; export const LEGEND_ITEM_WIDTH = 20;
+2 -3
View File
@@ -18,7 +18,6 @@
"ppmt-ip-share-history": { "key": "ppmt-ip-share-history", "title": "各 IP 半年收入占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_ip_share_history.csv", "annual": "data/ppmt/ppmt_ip_share_history_annual.csv", "halfyear": "data/ppmt/ppmt_ip_share_history_halfyear.csv" }, "height": 700, "filename": "泡泡玛特各IP半年收入占比" }, "ppmt-ip-share-history": { "key": "ppmt-ip-share-history", "title": "各 IP 半年收入占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_ip_share_history.csv", "annual": "data/ppmt/ppmt_ip_share_history_annual.csv", "halfyear": "data/ppmt/ppmt_ip_share_history_halfyear.csv" }, "height": 700, "filename": "泡泡玛特各IP半年收入占比" },
"ppmt-ip-ex-monsters": { "key": "ppmt-ip-ex-monsters", "title": "除 THE MONSTERS 外的 IP 收入及同比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_ip_ex_monsters.csv", "annual": "data/ppmt/ppmt_ip_ex_monsters_annual.csv", "halfyear": "data/ppmt/ppmt_ip_ex_monsters_halfyear.csv" }, "height": 700, "filename": "泡泡玛特除MONSTERS外IP收入及同比" }, "ppmt-ip-ex-monsters": { "key": "ppmt-ip-ex-monsters", "title": "除 THE MONSTERS 外的 IP 收入及同比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_ip_ex_monsters.csv", "annual": "data/ppmt/ppmt_ip_ex_monsters_annual.csv", "halfyear": "data/ppmt/ppmt_ip_ex_monsters_halfyear.csv" }, "height": 700, "filename": "泡泡玛特除MONSTERS外IP收入及同比" },
"ppmt-category": { "key": "ppmt-category", "title": "品类收入结构", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_category.csv", "annual": "data/ppmt/ppmt_category_annual.csv", "halfyear": "data/ppmt/ppmt_category_halfyear.csv" }, "height": 680, "filename": "泡泡玛特品类结构" }, "ppmt-category": { "key": "ppmt-category", "title": "品类收入结构", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_category.csv", "annual": "data/ppmt/ppmt_category_annual.csv", "halfyear": "data/ppmt/ppmt_category_halfyear.csv" }, "height": 680, "filename": "泡泡玛特品类结构" },
"ppmt-region-channel": { "key": "ppmt-region-channel", "title": "区域渠道分布", "csv": "data/ppmt/ppmt_region_channel_2026h1.csv", "height": 680, "filename": "泡泡玛特区域渠道分布2026H1" },
"ppmt-region-yoy": { "key": "ppmt-region-yoy", "title": "区域渠道同比增速", "csv": "data/ppmt/ppmt_region_channel_yoy.csv", "height": 620, "filename": "泡泡玛特区域渠道同比增速" }, "ppmt-region-yoy": { "key": "ppmt-region-yoy", "title": "区域渠道同比增速", "csv": "data/ppmt/ppmt_region_channel_yoy.csv", "height": 620, "filename": "泡泡玛特区域渠道同比增速" },
"ppmt-region-yoy-timeseries": { "key": "ppmt-region-yoy-timeseries", "title": "区域渠道同比增速趋势", "csv": "data/ppmt/ppmt_region_channel_yoy_timeseries.csv", "height": 760, "filename": "泡泡玛特区域渠道同比增速趋势" }, "ppmt-region-yoy-timeseries": { "key": "ppmt-region-yoy-timeseries", "title": "区域渠道同比增速趋势", "csv": "data/ppmt/ppmt_region_channel_yoy_timeseries.csv", "height": 760, "filename": "泡泡玛特区域渠道同比增速趋势" },
"ppmt-cash-conversion": { "key": "ppmt-cash-conversion", "title": "经营现金流与净利润", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_cash_conversion.csv", "annual": "data/ppmt/ppmt_cash_conversion_annual.csv", "halfyear": "data/ppmt/ppmt_cash_conversion_halfyear.csv" }, "height": 700, "filename": "泡泡玛特经营现金流与净利润" }, "ppmt-cash-conversion": { "key": "ppmt-cash-conversion", "title": "经营现金流与净利润", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_cash_conversion.csv", "annual": "data/ppmt/ppmt_cash_conversion_annual.csv", "halfyear": "data/ppmt/ppmt_cash_conversion_halfyear.csv" }, "height": 700, "filename": "泡泡玛特经营现金流与净利润" },
@@ -44,12 +43,12 @@
"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": "泡泡玛特分地区营收占比" } "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": [ "tabs": [
{ "id": "overview", "name": "概览", "chartRefs": ["ppmt-growth", "ppmt-profit", "ppmt-ip-share", "ppmt-region-channel"] }, { "id": "overview", "name": "概览", "chartRefs": ["ppmt-growth", "ppmt-profit", "ppmt-ip-share"] },
{ "id": "profitability", "name": "盈利能力", "chartRefs": ["ppmt-growth", "ppmt-core-profit", "ppmt-core-margin", "ppmt-bridge", "ppmt-sankey", "ppmt-cash-conversion", "ppmt-cashflow-composition"] }, { "id": "profitability", "name": "盈利能力", "chartRefs": ["ppmt-growth", "ppmt-core-profit", "ppmt-core-margin", "ppmt-bridge", "ppmt-sankey", "ppmt-cash-conversion", "ppmt-cashflow-composition"] },
{ "id": "expense", "name": "费用率", "chartRefs": ["ppmt-margins", "ppmt-expense-structure"] }, { "id": "expense", "name": "费用率", "chartRefs": ["ppmt-margins", "ppmt-expense-structure"] },
{ "id": "balance-sheet", "name": "资产负债", "chartRefs": ["ppmt-assets", "ppmt-inventory", "ppmt-liquidity-working-capital", "ppmt-working-capital-days"] }, { "id": "balance-sheet", "name": "资产负债", "chartRefs": ["ppmt-assets", "ppmt-inventory", "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": "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", "ppmt-channel-share", "ppmt-channel-share-china", "ppmt-channel-amount-china", "ppmt-channel-share-apac", "ppmt-channel-amount-apac", "ppmt-channel-share-americas", "ppmt-channel-amount-americas", "ppmt-channel-share-europe", "ppmt-channel-amount-europe", "ppmt-stores-retail", "ppmt-stores-retail-share", "ppmt-stores-robot", "ppmt-region-share"] }, { "id": "region-channel", "name": "区域渠道", "chartRefs": ["ppmt-region-yoy", "ppmt-region-yoy-timeseries", "ppmt-channel-revenue", "ppmt-channel-share", "ppmt-channel-share-china", "ppmt-channel-amount-china", "ppmt-channel-share-apac", "ppmt-channel-amount-apac", "ppmt-channel-share-americas", "ppmt-channel-amount-americas", "ppmt-channel-share-europe", "ppmt-channel-amount-europe", "ppmt-stores-retail", "ppmt-stores-retail-share", "ppmt-stores-robot", "ppmt-region-share"] },
{ "id": "store-revenue", "name": "单店收入", "chartRefs": ["ppmt-retail-store-revenue", "ppmt-robot-store-revenue"] } { "id": "store-revenue", "name": "单店收入", "chartRefs": ["ppmt-retail-store-revenue", "ppmt-robot-store-revenue"] }
] ]
}, },