feat: 泡泡玛特增加年度、半年数据区分
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
// 左轴:核心利润(亿元)
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user