feat: 泡泡玛特增加年度、半年数据区分

This commit is contained in:
2026-08-30 14:49:27 +08:00
parent d010b107aa
commit d51890a450
69 changed files with 1109 additions and 258 deletions
+6 -5
View File
@@ -1,6 +1,7 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
financialViewLabel,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
@@ -8,15 +9,15 @@ import {
* 泡泡玛特资产结构演变图
* 堆叠柱状图展示资产配置,折线图展示总资产增长
*/
export function assetsChart(rows, { width, height }) {
export function assetsChart(rows, { width, height, view = 'all' }) {
const periods = col(rows, 'period');
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特资产结构演变(2020H1-2026H1)',
subtext: '单位:亿元',
text: `泡泡玛特资产结构演变(${financialViewLabel(view)})`,
subtext: '单位:亿元;资产负债表为期末余额,H2 为年末时点而非全年累计',
},
legend: {
...legendStyle,
@@ -29,7 +30,7 @@ export function assetsChart(rows, { width, height }) {
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
},
xAxis: { ...periodAxis, data: periods },
xAxis: { ...standalonePeriodAxis, data: periods },
yAxis: [
{
...axisCommon,
+14 -10
View File
@@ -1,19 +1,23 @@
import { col } from '../csv.js';
import {
FINANCIAL_COLORS, PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
FINANCIAL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
financialViewDisclosure, financialViewLabel,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/** 泡泡玛特经营现金流与净利润:观察利润的现金兑现能力。 */
export function cashConversionChart(rows, { width, height }) {
export function cashConversionChart(rows, { width, height, view = 'all' }) {
const periods = col(rows, 'period');
const conversionMax = Math.max(...col(rows, 'cash_conversion').filter(Number.isFinite));
const conversionAxisMax = Math.max(200, Math.ceil((conversionMax * 1.1) / 100) * 100);
const barWidth = view === 'all' ? 14 : view === 'halfyear' ? 24.96 : 31.2;
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特经营现金流与净利润(年度)',
subtext: '现金转换率 = 经营活动现金流净额 / 净利润;单位:亿元',
text: `泡泡玛特经营现金流与净利润(${financialViewLabel(view)})`,
subtext: ['现金转换率 = 经营活动现金流净额 / 净利润;单位:亿元', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
legend: {
...legendStyle,
@@ -27,7 +31,7 @@ export function cashConversionChart(rows, { width, height }) {
textStyle: { fontFamily: FONT },
valueFormatter: (value) => `${value.toFixed(1)}`,
},
xAxis: { ...periodAxis, data: periods },
xAxis: { ...standalonePeriodAxis, data: periods },
yAxis: [
{
...axisCommon,
@@ -48,8 +52,8 @@ export function cashConversionChart(rows, { width, height }) {
nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
max: 200,
interval: 50,
max: conversionAxisMax,
interval: conversionAxisMax / 4,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
},
@@ -59,7 +63,7 @@ export function cashConversionChart(rows, { width, height }) {
name: '经营活动现金流净额',
type: 'bar',
data: col(rows, 'operating_cash_flow'),
barWidth: 31.2,
barWidth,
itemStyle: { color: FINANCIAL_COLORS.operatingCashFlow },
label: { show: true, position: 'top', color: PALETTE.ink, fontFamily: FONT, fontSize: 11, formatter: (p) => p.value.toFixed(1) },
},
@@ -67,7 +71,7 @@ export function cashConversionChart(rows, { width, height }) {
name: '净利润',
type: 'bar',
data: col(rows, 'net_profit'),
barWidth: 31.2,
barWidth,
itemStyle: { color: FINANCIAL_COLORS.netProfit },
label: { show: true, position: 'top', color: PALETTE.ink, fontFamily: FONT, fontSize: 11, formatter: (p) => p.value.toFixed(1) },
},
@@ -85,7 +89,7 @@ export function cashConversionChart(rows, { width, height }) {
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特合并现金流量表、利润表(年度口径)| @思考的Joey'),
footnote('数据来源:泡泡玛特合并现金流量表、利润表;H2为单独半年 | @思考的Joey'),
],
};
}
+11 -9
View File
@@ -1,12 +1,14 @@
import { col } from '../csv.js';
import {
FINANCIAL_COLORS, PALETTE, FONT, axisCommon, periodAxis, titleStyle, standardLegend,
FINANCIAL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend,
financialViewDisclosure, financialViewLabel,
lineDataWithSafeLabels,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/** 泡泡玛特三大现金流:展示经营造血、投资投入与筹资变化。 */
export function cashflowCompositionChart(rows, { width, height }) {
export function cashflowCompositionChart(rows, { width, height, view = 'all' }) {
const barWidth = view === 'all' ? 10 : view === 'halfyear' ? 18.2 : 26;
const netCashIncrease = lineDataWithSafeLabels(rows, {
lineKey: 'net_cash_increase',
barKeys: ['operating_cash_flow', 'investing_cash_flow', 'financing_cash_flow'],
@@ -16,8 +18,8 @@ export function cashflowCompositionChart(rows, { width, height }) {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特三大现金流与现金净增加(年度)',
subtext: '单位:亿元;正值为流入,负值为流出',
text: `泡泡玛特三大现金流与现金净增加(${financialViewLabel(view)})`,
subtext: ['单位:亿元;正值为流入,负值为流出', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
// 不手工指定 icon,让 ECharts 按系列类型生成:柱为矩形、折线为短线 + 空心圆。
legend: standardLegend(['经营活动现金流', '投资活动现金流', '筹资活动现金流', '现金净增加']),
@@ -28,7 +30,7 @@ export function cashflowCompositionChart(rows, { width, height }) {
textStyle: { fontFamily: FONT },
valueFormatter: (value) => `${value.toFixed(1)} 亿元`,
},
xAxis: { ...periodAxis, data: col(rows, 'period') },
xAxis: { ...standalonePeriodAxis, data: col(rows, 'period') },
yAxis: {
...axisCommon,
type: 'value',
@@ -38,9 +40,9 @@ export function cashflowCompositionChart(rows, { width, height }) {
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
},
series: [
{ name: '经营活动现金流', type: 'bar', data: col(rows, 'operating_cash_flow'), barWidth: 26, itemStyle: { color: FINANCIAL_COLORS.operatingCashFlow } },
{ name: '投资活动现金流', type: 'bar', data: col(rows, 'investing_cash_flow'), barWidth: 26, itemStyle: { color: FINANCIAL_COLORS.investingCashFlow } },
{ name: '筹资活动现金流', type: 'bar', data: col(rows, 'financing_cash_flow'), barWidth: 26, itemStyle: { color: FINANCIAL_COLORS.financingCashFlow } },
{ name: '经营活动现金流', type: 'bar', data: col(rows, 'operating_cash_flow'), barWidth, itemStyle: { color: FINANCIAL_COLORS.operatingCashFlow } },
{ name: '投资活动现金流', type: 'bar', data: col(rows, 'investing_cash_flow'), barWidth, itemStyle: { color: FINANCIAL_COLORS.investingCashFlow } },
{ name: '筹资活动现金流', type: 'bar', data: col(rows, 'financing_cash_flow'), barWidth, itemStyle: { color: FINANCIAL_COLORS.financingCashFlow } },
{
...lineSeries({
name: '现金净增加',
@@ -55,7 +57,7 @@ export function cashflowCompositionChart(rows, { width, height }) {
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特合并现金流量表(年度口径)| @思考的Joey'),
footnote('数据来源:泡泡玛特合并现金流量表;H2为单独半年 | @思考的Joey'),
],
};
}
+6 -12
View File
@@ -1,6 +1,7 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, legendStyle,
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
financialViewDisclosure, financialViewLabel,
footnote, watermarks, LAYOUT,
} from '../theme.js';
@@ -8,7 +9,7 @@ import {
* 泡泡玛特品类收入结构图
* 堆叠柱状图展示各品类的收入占比和增长
*/
export function categoryChart(rows, { width, height }) {
export function categoryChart(rows, { width, height, view = 'all' }) {
// 过滤掉表头行("品类"那一行)
const dataRows = rows.filter(r => r.period && r.period !== '品类');
const periods = col(dataRows, 'period');
@@ -17,8 +18,8 @@ export function categoryChart(rows, { width, height }) {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特品类收入结构(2024H1-2025H2)',
subtext: '单位:亿元',
text: `泡泡玛特品类收入结构(${financialViewLabel(view)})`,
subtext: ['单位:亿元;H2为下半年单期', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
legend: {
...legendStyle,
@@ -41,14 +42,7 @@ export function categoryChart(rows, { width, height }) {
return result;
},
},
xAxis: {
...categoryAxis,
data: periods,
axisLabel: {
...categoryAxis.axisLabel,
formatter: (value) => String(value).replace(/^20/, ''),
},
},
xAxis: { ...standalonePeriodAxis, data: periods },
yAxis: {
...axisCommon,
type: 'value',
+6 -5
View File
@@ -1,11 +1,12 @@
import { col } from '../csv.js';
import {
CHANNEL_COLORS, PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
CHANNEL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
financialViewDisclosure, financialViewLabel,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/** 泡泡玛特渠道收入结构与增速。 */
export function channelRevenueChart(rows, { width, height }) {
export function channelRevenueChart(rows, { width, height, view = 'all' }) {
const channels = [
{ key: 'retail', name: '零售店', color: CHANNEL_COLORS.retail },
{ key: 'online', name: '线上', color: CHANNEL_COLORS.online },
@@ -17,8 +18,8 @@ export function channelRevenueChart(rows, { width, height }) {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特渠道收入结构与增速',
subtext: '渠道收入为堆叠柱;折线为营业收入同比增速',
text: `泡泡玛特渠道收入结构与增速(${financialViewLabel(view)})`,
subtext: ['渠道收入为堆叠柱;折线为营业收入同比增速', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
legend: {
...legendStyle,
@@ -32,7 +33,7 @@ export function channelRevenueChart(rows, { width, height }) {
textStyle: { fontFamily: FONT },
valueFormatter: (value) => value.toFixed(1),
},
xAxis: { ...periodAxis, data: col(rows, 'period') },
xAxis: { ...standalonePeriodAxis, data: col(rows, 'period') },
yAxis: [
{
...axisCommon,
+6 -5
View File
@@ -1,6 +1,7 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, periodAxis, titleStyle,
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle,
financialViewDisclosure, financialViewLabel,
lineSeries, standardLegend, footnote, watermarks, LAYOUT,
} from '../theme.js';
@@ -15,15 +16,15 @@ import {
*/
const LINE_COLOR = '#7B2D42';
export function ppmtCoreMarginChart(rows, { width, height }) {
export function ppmtCoreMarginChart(rows, { width, height, view = 'all' }) {
const pct = (p) => `${p.value.toFixed(1)}%`;
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特核心利润率(20H1-26H1)',
subtext: '核心利润率 = 毛利率 − 销售费用率 − 管理费用率,剔除汇兑与投资等非主业扰动',
text: `泡泡玛特核心利润率(${financialViewLabel(view)})`,
subtext: ['核心利润率 = 毛利率 − 销售费用率 − 管理费用率,剔除汇兑与投资等非主业扰动', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
legend: standardLegend(['核心利润率'], { itemGap: 32 }),
grid: { left: 78, right: 60, top: LAYOUT.gridTop, bottom: 90 },
@@ -32,7 +33,7 @@ export function ppmtCoreMarginChart(rows, { width, height }) {
valueFormatter: (v) => `${v.toFixed(1)}%`,
textStyle: { fontFamily: FONT },
},
xAxis: { ...periodAxis, data: col(rows, 'period') },
xAxis: { ...standalonePeriodAxis, data: col(rows, 'period') },
yAxis: {
...axisCommon,
type: 'value',
+6 -5
View File
@@ -1,6 +1,7 @@
import { col } from '../csv.js';
import {
PALETTE, FINANCIAL_COLORS, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
PALETTE, FINANCIAL_COLORS, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
financialViewDisclosure, financialViewLabel,
footnote, watermarks, LAYOUT,
} from '../theme.js';
@@ -9,7 +10,7 @@ import {
* 核心利润 = 毛利 - 销售费用 - 管理费用,反映主业盈利能力去除营销和管理成本后的净额。
* 柱状图展示绝对值(亿元),折线图展示同比增速(%)。
*/
export function ppmtCoreProfitChart(rows, { width, height }) {
export function ppmtCoreProfitChart(rows, { width, height, view = 'all' }) {
const periods = col(rows, 'period');
const core = col(rows, 'core_profit');
const yoy = col(rows, 'yoy');
@@ -18,8 +19,8 @@ export function ppmtCoreProfitChart(rows, { width, height }) {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特核心利润及同比增速(20H1-26H1)',
subtext: '核心利润 = 毛利 - 销售费用 - 管理费用,单位:亿元',
text: `泡泡玛特核心利润及同比增速(${financialViewLabel(view)})`,
subtext: ['核心利润 = 毛利 - 销售费用 - 管理费用,单位:亿元', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
legend: {
...legendStyle,
@@ -49,7 +50,7 @@ export function ppmtCoreProfitChart(rows, { width, height }) {
return result;
},
},
xAxis: { ...periodAxis, data: periods },
xAxis: { ...standalonePeriodAxis, data: periods },
yAxis: [
{
// 左轴:核心利润(亿元)
+7 -6
View File
@@ -1,17 +1,18 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
financialViewDisclosure, financialViewLabel,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/** 泡泡玛特期间费用结构:观察销售投入、管理成本与费用率的规模效应。 */
export function expenseStructureChart(rows, { width, height }) {
export function expenseStructureChart(rows, { width, height, view = 'all' }) {
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特费用结构与费用率(年度)',
subtext: '销售、管理、财务费用;折线为四项费用率',
text: `泡泡玛特费用结构与费用率(${financialViewLabel(view)})`,
subtext: ['销售、管理、财务费用;折线为四项费用率', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
legend: {
...legendStyle,
@@ -25,7 +26,7 @@ export function expenseStructureChart(rows, { width, height }) {
textStyle: { fontFamily: FONT },
valueFormatter: (value) => value.toFixed(1),
},
xAxis: { ...periodAxis, data: col(rows, 'period') },
xAxis: { ...standalonePeriodAxis, data: col(rows, 'period') },
yAxis: [
{
...axisCommon,
@@ -68,7 +69,7 @@ export function expenseStructureChart(rows, { width, height }) {
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特合并利润表(年度口径)| @思考的Joey'),
footnote('数据来源:泡泡玛特合并利润表;H2为单独半年 | @思考的Joey'),
],
};
}
+7 -6
View File
@@ -1,6 +1,7 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, periodAxis, titleStyle, standardLegend,
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend,
financialViewDisclosure, financialViewLabel,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
@@ -43,15 +44,15 @@ function labeledData(rows, key) {
});
}
export function ppmtGrowthChart(rows, { width, height }) {
export function ppmtGrowthChart(rows, { width, height, view = 'all' }) {
const pct = (p) => `${p.value}%`;
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特营收到利润的增速对比(2020H1-2026H1)',
subtext: '同比增速(%),净利增速显著高于营收增速体现利润弹性',
text: `泡泡玛特营收到利润的增速对比(${financialViewLabel(view)})`,
subtext: ['同比增速(%),净利增速显著高于营收增速体现利润弹性', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
legend: standardLegend(['非GAAP归母净利', '毛利润', '营业收入', '营业成本'], { itemGap: 34 }),
grid: { left: 84, right: 56, top: LAYOUT.gridTop, bottom: 80 },
@@ -60,7 +61,7 @@ export function ppmtGrowthChart(rows, { width, height }) {
valueFormatter: (v) => `${v}%`,
textStyle: { fontFamily: FONT },
},
xAxis: { ...periodAxis, data: col(rows, 'period') },
xAxis: { ...standalonePeriodAxis, data: col(rows, 'period') },
yAxis: {
...axisCommon,
type: 'value',
@@ -105,7 +106,7 @@ export function ppmtGrowthChart(rows, { width, height }) {
})),
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特财报,2020H1/H2非GAAP净利用归母净利替代 | @思考的Joey'),
footnote('数据来源:泡泡玛特财报;2020H1/H2非GAAP净利用归母净利替代 | @思考的Joey'),
],
};
}
+5 -4
View File
@@ -1,6 +1,7 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, legendStyle,
financialViewDisclosure, financialViewLabel, shortStandalonePeriod,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
@@ -8,7 +9,7 @@ import {
* 泡泡玛特主要IP收入趋势图
* 多条折线展示各IP的收入变化
*/
export function ipChart(rows, { width, height }) {
export function ipChart(rows, { width, height, view = 'all' }) {
// 提取时期(从第一行的列名)
const periods = Object.keys(rows[0]).filter(k => k !== 'ip');
@@ -42,8 +43,8 @@ export function ipChart(rows, { width, height }) {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特主要IP收入趋势(2021H1-2025H2)',
subtext: '单位:亿元',
text: `泡泡玛特主要IP收入趋势(${financialViewLabel(view)})`,
subtext: ['单位:亿元;H2为单独半年', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
legend: {
...legendStyle,
@@ -62,7 +63,7 @@ export function ipChart(rows, { width, height }) {
data: periods,
axisLabel: {
...categoryAxis.axisLabel,
formatter: (value) => String(value).replace(/^20/, ''),
formatter: shortStandalonePeriod,
},
},
yAxis: {
+10 -15
View File
@@ -1,6 +1,7 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, labelStyle,
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, labelStyle,
financialViewDisclosure, financialViewLabel,
barSeries, lineSeries, standardLegend, footnote, watermarks, LAYOUT,
} from '../theme.js';
@@ -34,13 +35,13 @@ const BAR_COLOR = '#176B52'; // 墨翡翠
const LINE_COLOR = '#7A3FE0'; // 鲜亮紫
const LINE_ON_BAR = '#DCC9FF'; // 压在翡翠柱面上时用的浅紫(对比度约 6:1)
const Y_MAX = 160; // 左轴上限
const YOY_MIN = -50; // 右轴下限
const YOY_MAX = 150; // 右轴上限
export function ppmtIpExMonstersChart(rows, { width, height }) {
export function ppmtIpExMonstersChart(rows, { width, height, view = 'all' }) {
const periods = col(rows, 'period');
const revenue = col(rows, 'revenue');
const yMax = Math.max(...revenue) > 160 ? 300 : 160;
// 前两期无同比基数,CSV 里留空;parseCSV 会给出 '',统一转成 null
const yoy = col(rows, 'yoy').map((v) => (v === '' ? null : v));
@@ -50,7 +51,7 @@ export function ppmtIpExMonstersChart(rows, { width, height }) {
const labelGap = (8 + 13) / plotH; // labelStyle 的 distance + 字高,换成占比
const yoyData = yoy.map((v, i) => {
if (v === null) return null;
const barFrac = revenue[i] / Y_MAX;
const barFrac = revenue[i] / yMax;
const lineFrac = (v - YOY_MIN) / (YOY_MAX - YOY_MIN);
// 标签整体(底边到顶边)都落在柱子高度以内 → 压在柱面上
const onBar = lineFrac + labelGap < barFrac;
@@ -61,8 +62,8 @@ export function ppmtIpExMonstersChart(rows, { width, height }) {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特除 THE MONSTERS 外的IP收入及同比(21H1-26H1)',
subtext: 'IP收入合计 − THE MONSTERS,单位:亿元;H2 为下半年单期,非全年累计',
text: `泡泡玛特除 THE MONSTERS 外的IP收入及同比(${financialViewLabel(view)})`,
subtext: ['IP收入合计 − THE MONSTERS,单位:亿元;H2为下半年单期,非全年累计', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
legend: standardLegend(['除MONSTERS外收入', '同比增速'], { itemGap: 32 }),
grid: { left: 78, right: 128, top: LAYOUT.gridTop, bottom: 90 },
@@ -82,12 +83,7 @@ export function ppmtIpExMonstersChart(rows, { width, height }) {
return out;
},
},
xAxis: {
...categoryAxis,
data: periods,
// 只去世纪前缀:这张表的 H2 是单半年,不能简写成「全年」
axisLabel: { ...categoryAxis.axisLabel, formatter: (v) => v.replace(/^20/, '') },
},
xAxis: { ...standalonePeriodAxis, data: periods },
yAxis: [
{
...axisCommon,
@@ -97,9 +93,8 @@ export function ppmtIpExMonstersChart(rows, { width, height }) {
nameGap: 50,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
// 定死 160:留出顶部余量,最高柱(139.0)不会顶到绘图区上沿
max: Y_MAX,
interval: 20,
max: yMax,
interval: yMax === 160 ? 20 : 50,
},
{
type: 'value',
+5 -4
View File
@@ -1,5 +1,6 @@
import {
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, LEGEND_ITEM_WIDTH,
financialViewDisclosure, financialViewLabel, shortStandalonePeriod,
barSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
@@ -38,7 +39,7 @@ const COLORS = {
const LEGEND_WIDTH = 160;
const LEGEND_RIGHT = 20;
export function ppmtIpShareHistoryChart(rows, { width, height }) {
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);
const totals = periods.map((p) => rows.reduce((s, r) => s + r[p], 0));
@@ -49,8 +50,8 @@ export function ppmtIpShareHistoryChart(rows, { width, height }) {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特各IP半年收入占比(21H1-26H1)',
subtext: '占当期IP收入合计的比重;H2 为下半年单期,非全年累计',
text: `泡泡玛特各IP收入占比(${financialViewLabel(view)})`,
subtext: ['占当期IP收入合计的比重;H2为下半年单期,非全年累计', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
legend: {
orient: 'vertical',
@@ -89,7 +90,7 @@ export function ppmtIpShareHistoryChart(rows, { width, height }) {
...categoryAxis,
data: periods,
// 只去世纪前缀:这张表的 H2 是单半年,不能简写成「全年」
axisLabel: { ...categoryAxis.axisLabel, formatter: (v) => v.replace(/^20/, '') },
axisLabel: { ...categoryAxis.axisLabel, formatter: shortStandalonePeriod },
},
yAxis: {
...axisCommon,
+6 -5
View File
@@ -1,19 +1,20 @@
import { col } from '../csv.js';
import {
PALETTE, FINANCIAL_COLORS, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
PALETTE, FINANCIAL_COLORS, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
financialViewLabel,
lineDataWithSafeLabels, lineSeries, barSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/** 泡泡玛特现金、负债与净营运资本:观察流动性与资金占用。 */
export function liquidityWorkingCapitalChart(rows, { width, height }) {
export function liquidityWorkingCapitalChart(rows, { width, height, view = 'all' }) {
const periods = col(rows, 'period');
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特现金、负债与净营运资本',
subtext: '净营运资本 = 流动资产 − 流动负债;单位:亿元',
text: `泡泡玛特现金、负债与净营运资本(${financialViewLabel(view)})`,
subtext: '净营运资本 = 流动资产 − 流动负债;单位:亿元;H2 为年末时点',
},
legend: {
...legendStyle,
@@ -27,7 +28,7 @@ export function liquidityWorkingCapitalChart(rows, { width, height }) {
textStyle: { fontFamily: FONT },
valueFormatter: (value) => `${value.toFixed(1)} 亿元`,
},
xAxis: { ...periodAxis, data: periods },
xAxis: { ...standalonePeriodAxis, data: periods },
yAxis: {
...axisCommon,
type: 'value',
+6 -5
View File
@@ -1,6 +1,7 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, periodAxis, titleStyle, standardLegend,
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend,
financialViewDisclosure, financialViewLabel,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
@@ -54,15 +55,15 @@ function labeledData(rows, key) {
});
}
export function ppmtMarginsChart(rows, { width, height }) {
export function ppmtMarginsChart(rows, { width, height, view = 'all' }) {
const pct = (p) => `${p.value}%`;
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特利润率与费用率拆解(2020H1-2026H1)',
subtext: '毛利率抬升叠加费用率下降,共同推动净利率走高(H1为半年,H2为全年累计)',
text: `泡泡玛特利润率与费用率拆解(${financialViewLabel(view)})`,
subtext: ['毛利率抬升叠加费用率下降,共同推动净利率走高', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
legend: standardLegend(SERIES.map((series) => series.name)),
grid: { left: 78, right: 56, top: LAYOUT.gridTop, bottom: 80 },
@@ -71,7 +72,7 @@ export function ppmtMarginsChart(rows, { width, height }) {
valueFormatter: (v) => `${v}%`,
textStyle: { fontFamily: FONT },
},
xAxis: { ...periodAxis, data: col(rows, 'period') },
xAxis: { ...standalonePeriodAxis, data: col(rows, 'period') },
yAxis: {
...axisCommon,
type: 'value',
+12 -11
View File
@@ -1,6 +1,7 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
financialViewDisclosure, financialViewLabel,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
@@ -8,19 +9,19 @@ import {
* 泡泡玛特盈利能力分析图
* 柱状图展示收入和利润,折线图展示毛利率
*/
export function profitChart(rows, { width, height }) {
const periods = col(rows, 'period').reverse(); // 按时间正序
const revenue = col(rows, 'revenue').reverse();
const grossProfit = col(rows, 'gross_profit').reverse();
const netProfit = col(rows, 'net_profit').reverse();
const grossMargin = col(rows, 'gross_margin').reverse();
export function profitChart(rows, { width, height, view = 'all' }) {
const periods = col(rows, 'period');
const revenue = col(rows, 'revenue');
const grossProfit = col(rows, 'gross_profit');
const netProfit = col(rows, 'net_profit');
const grossMargin = col(rows, 'gross_margin');
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特盈利能力分析(2022H2-2026H1)',
subtext: '营收、毛利、净利润及毛利率变化',
text: `泡泡玛特盈利能力分析(${financialViewLabel(view)})`,
subtext: ['营收、毛利、净利润及毛利率变化', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
legend: {
...legendStyle,
@@ -33,7 +34,7 @@ export function profitChart(rows, { width, height }) {
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
},
xAxis: { ...periodAxis, data: periods },
xAxis: { ...standalonePeriodAxis, data: periods },
yAxis: [
{
...axisCommon,
@@ -122,7 +123,7 @@ export function profitChart(rows, { width, height }) {
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特财报(累计数据)| @思考的Joey'),
footnote('数据来源:泡泡玛特财报;H2为单独半年 | @思考的Joey'),
],
};
}
+35 -5
View File
@@ -11,6 +11,7 @@ import {
const instances = new Map();
const activeViews = new Map();
const chartRenderVersions = new Map();
let manifest;
let shell;
let state;
@@ -106,25 +107,47 @@ function chartSource(chart) {
return chart.views[view];
}
async function renderChart(chart, version) {
async function renderChart(chart, version, chartVersion) {
const el = document.getElementById(`chart-${chart.instanceId}`);
if (!el) return;
const source = chartSource(chart);
const view = chart.views
? activeViews.get(chart.viewKey) || chart.defaultView
: undefined;
try {
const rows = await loadCSV(`${source}?t=${cacheToken}`);
if (version !== renderVersion || !document.body.contains(el)) return;
if (version !== renderVersion || chartVersion !== chartRenderVersions.get(chart.instanceId) || !document.body.contains(el)) return;
const instance = echarts.init(el, null, { renderer: 'canvas' });
instances.set(chart.instanceId, instance);
const { width, height } = el.getBoundingClientRect();
instance.setOption(getChartBuilder(chart.key)(rows, { width, height }), true);
instance.setOption(getChartBuilder(chart.key)(rows, { width, height, view }), true);
} catch (error) {
if (version === renderVersion) showChartError(chart.instanceId, error.message);
console.error(`渲染图表失败:${chart.id}`, error);
}
}
function renderSingleChart(chart) {
const previous = instances.get(chart.instanceId);
previous?.dispose();
instances.delete(chart.instanceId);
const chartVersion = (chartRenderVersions.get(chart.instanceId) || 0) + 1;
chartRenderVersions.set(chart.instanceId, chartVersion);
renderChart(chart, renderVersion, chartVersion);
}
function updateViewControls(chart, view) {
const card = document.getElementById(`card-${chart.instanceId}`);
if (!card) return;
card.querySelectorAll('.view-button').forEach((button) => {
button.dataset.active = String(button.dataset.view === view);
});
const source = card.querySelector('.src');
if (source) source.textContent = chartSource(chart);
}
function exportPNG(chartId, filename) {
const instance = instances.get(chartId);
if (!instance) return;
@@ -167,12 +190,19 @@ function renderCurrent() {
renderCards(shell.cards, charts, activeViews, {
onExport: exportPNG,
onSelectView: (chart, view) => {
if (activeViews.get(chart.viewKey) === view) return;
activeViews.set(chart.viewKey, view);
updateHash({ companyId: company.id, tabId: tab.id, chartId: chart.id, view });
state = normalizeState({ companyId: company.id, tabId: tab.id, chartId: chart.id, view });
history.pushState(null, '', serializeHash(state));
updateViewControls(chart, view);
renderSingleChart(chart);
},
});
renderAnchors(shell.chartNav, charts, selectAnchor);
Promise.all(charts.map((chart) => renderChart(chart, version)));
charts.forEach((chart) => {
chartRenderVersions.set(chart.instanceId, (chartRenderVersions.get(chart.instanceId) || 0) + 1);
});
Promise.all(charts.map((chart) => renderChart(chart, version, chartRenderVersions.get(chart.instanceId))));
}
async function start() {
+26
View File
@@ -108,6 +108,32 @@ export const periodAxis = {
},
};
/**
* 独立期间轴:FY 为全年,H1/H2 为单独半年。
* 用于已拆分累计值的多时间维度图,避免把 H2 错写为全年。
*/
export function shortStandalonePeriod(label) {
return String(label)
.replace(/^20(\d{2})FY$/, '$1全年')
.replace(/^20(\d{2})/, '$1');
}
export const standalonePeriodAxis = {
...categoryAxis,
axisLabel: {
...categoryAxis.axisLabel,
formatter: shortStandalonePeriod,
},
};
export function financialViewLabel(view) {
return ({ all: '全部期间', annual: '年度', halfyear: '半年', quarter: '季度' })[view] || '全部期间';
}
export function financialViewDisclosure(view) {
return '';
}
export const titleStyle = {
left: 'center',
top: 18,
+5 -3
View File
@@ -79,13 +79,15 @@ export function renderCards(container, charts, activeViews, { onExport, onSelect
if (viewNames.length > 1) {
const switcher = element('div', 'view-switcher');
const labels = { annual: '年度', halfyear: '半年', quarter: '季度' };
const labels = { all: '全部', annual: '年度', halfyear: '半年', quarter: '季度' };
viewNames.forEach((view) => {
switcher.append(button(labels[view] || view, {
const viewButton = button(labels[view] || view, {
active: view === activeView,
onClick: () => onSelectView(chart, view),
className: 'view-button',
}));
});
viewButton.dataset.view = view;
switcher.append(viewButton);
});
bar.append(switcher);
}