Refactor financial chart implementation

This commit is contained in:
R524809
2026-08-28 16:32:10 +08:00
parent 5ecc554aaa
commit 7aab3d7134
73 changed files with 2340 additions and 594 deletions
+1 -4
View File
@@ -18,10 +18,7 @@ export function growthChart(rows, { width, height }) {
...legendStyle,
top: LAYOUT.legendTop,
itemGap: 34,
data: ['非国际归母净利', '毛利润', '营业收入', '营业成本'].map(name => ({
name,
icon: 'emptyCircle', // 全部折线图
})),
data: ['非国际归母净利', '毛利润', '营业收入', '营业成本'],
},
grid: { left: 84, right: 56, top: LAYOUT.gridTop, bottom: 64 },
tooltip: {
+16
View File
@@ -8,7 +8,9 @@ import { profitChart } from './ppmt_profit.js';
import { ipChart } from './ppmt_ip.js';
import { categoryChart } from './ppmt_category.js';
import { sankeyChart } from './ppmt_sankey.js';
import { regionChannelChart } from './ppmt_region_channel.js';
import { regionChannelYoyChart } from './ppmt_region_yoy.js';
import { regionChannelYoyTimeseriesChart } from './ppmt_region_channel_yoy_timeseries.js';
import { ppmtGrowthChart } from './ppmt_growth.js';
import { ppmtMarginsChart } from './ppmt_margins.js';
import { ppmtBridgeChart } from './ppmt_bridge.js';
@@ -17,6 +19,12 @@ import { ppmtCoreMarginChart } from './ppmt_core_margin.js';
import { ppmtIpShareChart } from './ppmt_ip_share.js';
import { ppmtIpShareHistoryChart } from './ppmt_ip_share_history.js';
import { ppmtIpExMonstersChart } from './ppmt_ip_ex_monsters.js';
import { cashConversionChart } from './ppmt_cash_conversion.js';
import { liquidityWorkingCapitalChart } from './ppmt_liquidity_working_capital.js';
import { cashflowCompositionChart } from './ppmt_cashflow_composition.js';
import { workingCapitalDaysChart } from './ppmt_working_capital_days.js';
import { expenseStructureChart } from './ppmt_expense_structure.js';
import { channelRevenueChart } from './ppmt_channel_revenue.js';
/** 图表构建函数注册表。manifest 只引用这里的 key,不直接引用 JS 函数。 */
export const chartRegistry = {
@@ -30,7 +38,9 @@ export const chartRegistry = {
'ppmt-ip': ipChart,
'ppmt-category': categoryChart,
'ppmt-sankey': sankeyChart,
'ppmt-region-channel': regionChannelChart,
'ppmt-region-yoy': regionChannelYoyChart,
'ppmt-region-yoy-timeseries': regionChannelYoyTimeseriesChart,
'ppmt-growth': ppmtGrowthChart,
'ppmt-margins': ppmtMarginsChart,
'ppmt-bridge': ppmtBridgeChart,
@@ -39,6 +49,12 @@ export const chartRegistry = {
'ppmt-ip-share': ppmtIpShareChart,
'ppmt-ip-share-history': ppmtIpShareHistoryChart,
'ppmt-ip-ex-monsters': ppmtIpExMonstersChart,
'ppmt-cash-conversion': cashConversionChart,
'ppmt-liquidity-working-capital': liquidityWorkingCapitalChart,
'ppmt-cashflow-composition': cashflowCompositionChart,
'ppmt-working-capital-days': workingCapitalDaysChart,
'ppmt-expense-structure': expenseStructureChart,
'ppmt-channel-revenue': channelRevenueChart,
};
export function getChartBuilder(key) {
+1 -5
View File
@@ -17,11 +17,7 @@ export function inventoryChart(rows, { width, height }) {
legend: {
...legendStyle,
top: LAYOUT.legendTop,
data: [
{ name: '存货', icon: 'rect' },
{ name: '营收', icon: 'rect' },
{ name: '存货周转天数', icon: 'emptyCircle' }, // 折线
],
data: ['存货', '营收', '存货周转天数'],
},
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
+1 -4
View File
@@ -21,10 +21,7 @@ export function marginChart(rows, { width, height }) {
legend: {
...legendStyle,
top: LAYOUT.legendTop,
data: ['毛利率', '销售费用率', '管理费用率', '非国际归母净利率'].map(name => ({
name,
icon: 'emptyCircle', // 全部折线图
})),
data: ['毛利率', '销售费用率', '管理费用率', '非国际归母净利率'],
},
grid: { left: 72, right: 48, top: LAYOUT.gridTop, bottom: 62 },
tooltip: {
+1 -4
View File
@@ -21,10 +21,7 @@ export function assetsChart(rows, { width, height }) {
legend: {
...legendStyle,
top: LAYOUT.legendTop,
data: ['类现金', '应收账款', '投资资产', '经营类资产', '资产总计'].map(name => ({
name,
icon: name === '资产总计' ? 'emptyCircle' : 'rect', // 总计是折线,其他是柱
})),
data: ['类现金', '应收账款', '投资资产', '经营类资产', '资产总计'],
},
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
+91
View File
@@ -0,0 +1,91 @@
import { col } from '../csv.js';
import {
FINANCIAL_COLORS, PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/** 泡泡玛特经营现金流与净利润:观察利润的现金兑现能力。 */
export function cashConversionChart(rows, { width, height }) {
const periods = col(rows, 'period');
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特经营现金流与净利润(年度)',
subtext: '现金转换率 = 经营活动现金流净额 / 净利润;单位:亿元',
},
legend: {
...legendStyle,
top: LAYOUT.legendTop,
data: ['经营活动现金流净额', '净利润', '现金转换率'],
},
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
valueFormatter: (value) => `${value.toFixed(1)}`,
},
xAxis: { ...periodAxis, data: periods },
yAxis: [
{
...axisCommon,
type: 'value',
name: '金额(亿元)',
nameLocation: 'middle',
nameGap: 52,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
splitLine: { show: true, lineStyle: { color: PALETTE.gridLine } },
},
{
...axisCommon,
type: 'value',
name: '现金转换率(%)',
nameLocation: 'middle',
nameGap: 58,
nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
max: 200,
interval: 50,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
},
],
series: [
{
name: '经营活动现金流净额',
type: 'bar',
data: col(rows, 'operating_cash_flow'),
barWidth: 31.2,
itemStyle: { color: FINANCIAL_COLORS.operatingCashFlow },
label: { show: true, position: 'top', color: PALETTE.ink, fontFamily: FONT, fontSize: 11, formatter: (p) => p.value.toFixed(1) },
},
{
name: '净利润',
type: 'bar',
data: col(rows, 'net_profit'),
barWidth: 31.2,
itemStyle: { color: FINANCIAL_COLORS.netProfit },
label: { show: true, position: 'top', color: PALETTE.ink, fontFamily: FONT, fontSize: 11, formatter: (p) => p.value.toFixed(1) },
},
{
...lineSeries({
name: '现金转换率',
data: col(rows, 'cash_conversion'),
color: FINANCIAL_COLORS.cashConversion,
formatter: (p) => `${p.value.toFixed(0)}%`,
width: 3,
}),
yAxisIndex: 1,
z: 10,
},
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特合并现金流量表、利润表(年度口径)| @思考的Joey'),
],
};
}
+61
View File
@@ -0,0 +1,61 @@
import { col } from '../csv.js';
import {
FINANCIAL_COLORS, PALETTE, FONT, axisCommon, periodAxis, titleStyle, standardLegend,
lineDataWithSafeLabels,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/** 泡泡玛特三大现金流:展示经营造血、投资投入与筹资变化。 */
export function cashflowCompositionChart(rows, { width, height }) {
const netCashIncrease = lineDataWithSafeLabels(rows, {
lineKey: 'net_cash_increase',
barKeys: ['operating_cash_flow', 'investing_cash_flow', 'financing_cash_flow'],
});
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特三大现金流与现金净增加(年度)',
subtext: '单位:亿元;正值为流入,负值为流出',
},
// 不手工指定 icon,让 ECharts 按系列类型生成:柱为矩形、折线为短线 + 空心圆。
legend: standardLegend(['经营活动现金流', '投资活动现金流', '筹资活动现金流', '现金净增加']),
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
valueFormatter: (value) => `${value.toFixed(1)} 亿元`,
},
xAxis: { ...periodAxis, data: col(rows, 'period') },
yAxis: {
...axisCommon,
type: 'value',
name: '金额(亿元)',
nameLocation: 'middle',
nameGap: 52,
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 } },
{
...lineSeries({
name: '现金净增加',
data: netCashIncrease,
color: FINANCIAL_COLORS.netCashIncrease,
formatter: (p) => p.value.toFixed(1),
width: 3,
}),
labelLayout: { moveOverlap: 'shiftY', hideOverlap: true },
z: 10,
},
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特合并现金流量表(年度口径)| @思考的Joey'),
],
};
}
+10 -6
View File
@@ -1,6 +1,6 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, legendStyle,
footnote, watermarks, LAYOUT,
} from '../theme.js';
@@ -23,10 +23,7 @@ export function categoryChart(rows, { width, height }) {
legend: {
...legendStyle,
top: LAYOUT.legendTop,
data: ['毛绒', '手办', 'MEGA', '衍生品及其他'].map(name => ({
name,
icon: 'rect', // 全部是堆叠柱状图
})),
data: ['毛绒', '手办', 'MEGA', '衍生品及其他'],
},
grid: { left: 78, right: 56, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
@@ -44,7 +41,14 @@ export function categoryChart(rows, { width, height }) {
return result;
},
},
xAxis: { ...periodAxis, data: periods },
xAxis: {
...categoryAxis,
data: periods,
axisLabel: {
...categoryAxis.axisLabel,
formatter: (value) => String(value).replace(/^20/, ''),
},
},
yAxis: {
...axisCommon,
type: 'value',
+87
View File
@@ -0,0 +1,87 @@
import { col } from '../csv.js';
import {
CHANNEL_COLORS, PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/** 泡泡玛特渠道收入结构与增速。 */
export function channelRevenueChart(rows, { width, height }) {
const channels = [
{ key: 'retail', name: '零售店', color: CHANNEL_COLORS.retail },
{ key: 'online', name: '线上', color: CHANNEL_COLORS.online },
{ key: 'robot_store', name: '机器人商店', color: CHANNEL_COLORS.robotStore },
{ key: 'wholesale', name: '批发', color: CHANNEL_COLORS.wholesale },
];
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特渠道收入结构与增速',
subtext: '渠道收入为堆叠柱;折线为营业收入同比增速',
},
legend: {
...legendStyle,
top: LAYOUT.legendTop,
data: [...channels.map((item) => item.name), '营业收入同比'],
},
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
valueFormatter: (value) => value.toFixed(1),
},
xAxis: { ...periodAxis, data: col(rows, 'period') },
yAxis: [
{
...axisCommon,
type: 'value',
name: '渠道收入(亿元)',
nameLocation: 'middle',
nameGap: 56,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
},
{
...axisCommon,
type: 'value',
name: '营业收入同比(%)',
nameLocation: 'middle',
nameGap: 60,
nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
max: 250,
interval: 50,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
},
],
series: [
...channels.map((item) => ({
name: item.name,
type: 'bar',
stack: 'channel-revenue',
data: col(rows, item.key),
itemStyle: { color: item.color },
emphasis: { focus: 'series' },
})),
{
...lineSeries({
name: '营业收入同比',
data: col(rows, 'revenue_yoy'),
color: CHANNEL_COLORS.revenueGrowth,
formatter: (p) => `${p.value.toFixed(0)}%`,
width: 3,
}),
yAxisIndex: 1,
z: 10,
},
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特营收构成表;渠道口径按财报披露 | @思考的Joey'),
],
};
}
+1
View File
@@ -22,6 +22,7 @@ export function ppmtCoreProfitChart(rows, { width, height }) {
subtext: '核心利润 = 毛利 - 销售费用 - 管理费用,单位:亿元',
},
legend: {
...legendStyle,
top: LAYOUT.legendTop,
itemGap: 32,
textStyle: {
+74
View File
@@ -0,0 +1,74 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/** 泡泡玛特期间费用结构:观察销售投入、管理成本与费用率的规模效应。 */
export function expenseStructureChart(rows, { width, height }) {
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特费用结构与费用率(年度)',
subtext: '销售、管理、财务费用;折线为四项费用率',
},
legend: {
...legendStyle,
top: LAYOUT.legendTop,
data: ['销售费用', '管理费用', '财务费用(净额)', '四项费用率'],
},
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
valueFormatter: (value) => value.toFixed(1),
},
xAxis: { ...periodAxis, data: col(rows, 'period') },
yAxis: [
{
...axisCommon,
type: 'value',
name: '金额(亿元)',
nameLocation: 'middle',
nameGap: 52,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
},
{
...axisCommon,
type: 'value',
name: '四项费用率(%)',
nameLocation: 'middle',
nameGap: 58,
nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
max: 50,
interval: 10,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
},
],
series: [
{ name: '销售费用', type: 'bar', stack: 'expenses', data: col(rows, 'sales_expense'), itemStyle: { color: PALETTE.orange } },
{ name: '管理费用', type: 'bar', stack: 'expenses', data: col(rows, 'admin_expense'), itemStyle: { color: PALETTE.purple } },
{ name: '财务费用(净额)', type: 'bar', stack: 'expenses', data: col(rows, 'financial_expense'), itemStyle: { color: PALETTE.blue } },
{
...lineSeries({
name: '四项费用率',
data: col(rows, 'four_expense_rate'),
color: PALETTE.red,
formatter: (p) => `${p.value.toFixed(1)}%`,
width: 3,
}),
yAxisIndex: 1,
z: 10,
},
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特合并利润表(年度口径)| @思考的Joey'),
],
};
}
+2 -12
View File
@@ -1,6 +1,6 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
PALETTE, FONT, axisCommon, periodAxis, titleStyle, standardLegend,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
@@ -53,17 +53,7 @@ export function ppmtGrowthChart(rows, { width, height }) {
text: '泡泡玛特营收到利润的增速对比(2020H1-2026H1)',
subtext: '同比增速(%),净利增速显著高于营收增速体现利润弹性',
},
legend: {
// 不继承 legendStyle,让 ECharts 按系列自动生成「短线+空心圆」图例
top: LAYOUT.legendTop,
itemGap: 34,
textStyle: {
color: PALETTE.ink,
fontSize: 14,
fontFamily: FONT,
},
data: ['非GAAP归母净利', '毛利润', '营业收入', '营业成本'],
},
legend: standardLegend(['非GAAP归母净利', '毛利润', '营业收入', '营业成本'], { itemGap: 34 }),
grid: { left: 84, right: 56, top: LAYOUT.gridTop, bottom: 80 },
tooltip: {
trigger: 'axis',
+10 -6
View File
@@ -1,6 +1,6 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, legendStyle,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
@@ -49,10 +49,7 @@ export function ipChart(rows, { width, height }) {
...legendStyle,
top: LAYOUT.legendTop,
itemGap: 20,
data: rows.map(r => ({
name: r.ip,
icon: 'emptyCircle', // 折线图:空心圆圈
})),
data: rows.map((row) => row.ip),
},
grid: { left: 78, right: 56, top: LAYOUT.gridTop, bottom: 70 },
tooltip: {
@@ -60,7 +57,14 @@ export function ipChart(rows, { width, height }) {
valueFormatter: (v) => `${v.toFixed(2)} 亿元`,
textStyle: { fontFamily: FONT },
},
xAxis: { ...periodAxis, data: periods },
xAxis: {
...categoryAxis,
data: periods,
axisLabel: {
...categoryAxis.axisLabel,
formatter: (value) => String(value).replace(/^20/, ''),
},
},
yAxis: {
...axisCommon,
type: 'value',
+2 -2
View File
@@ -1,5 +1,5 @@
import {
PALETTE, FONT, axisCommon, categoryAxis, titleStyle,
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, LEGEND_ITEM_WIDTH,
barSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
@@ -58,7 +58,7 @@ export function ppmtIpShareHistoryChart(rows, { width, height }) {
width: LEGEND_WIDTH,
top: 'middle',
itemGap: 12,
itemWidth: 14,
itemWidth: LEGEND_ITEM_WIDTH,
itemHeight: 14,
data: names,
textStyle: { color: PALETTE.ink, fontSize: 12, fontFamily: FONT },
@@ -0,0 +1,73 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/** 泡泡玛特现金、负债与净营运资本:观察流动性与资金占用。 */
export function liquidityWorkingCapitalChart(rows, { width, height }) {
const periods = col(rows, 'period');
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特现金、负债与净营运资本',
subtext: '净营运资本 = 流动资产 − 流动负债;单位:亿元',
},
legend: {
...legendStyle,
top: LAYOUT.legendTop,
data: ['货币资金', '负债合计', '净营运资本'],
},
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
valueFormatter: (value) => `${value.toFixed(1)} 亿元`,
},
xAxis: { ...periodAxis, data: periods },
yAxis: {
...axisCommon,
type: 'value',
name: '金额(亿元)',
nameLocation: 'middle',
nameGap: 52,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
},
series: [
{
name: '货币资金',
type: 'bar',
data: col(rows, 'cash'),
barWidth: 18,
itemStyle: { color: PALETTE.blue },
emphasis: { focus: 'series' },
},
{
name: '负债合计',
type: 'bar',
data: col(rows, 'total_liabilities'),
barWidth: 18,
itemStyle: { color: PALETTE.orange },
emphasis: { focus: 'series' },
},
{
...lineSeries({
name: '净营运资本',
data: col(rows, 'net_working_capital'),
color: PALETTE.red,
formatter: (p) => p.value.toFixed(1),
width: 3,
}),
z: 10,
},
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特合并资产负债表 | @思考的Joey'),
],
};
}
+2 -13
View File
@@ -1,6 +1,6 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
PALETTE, FONT, axisCommon, periodAxis, titleStyle, standardLegend,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
@@ -64,18 +64,7 @@ export function ppmtMarginsChart(rows, { width, height }) {
text: '泡泡玛特利润率与费用率拆解(2020H1-2026H1)',
subtext: '毛利率抬升叠加费用率下降,共同推动净利率走高(H1为半年,H2为全年累计)',
},
legend: {
// 不继承 legendStyle:它带 icon:'roundRect',会盖掉 ECharts 按系列
// 自动生成的「短线+空心圆」图例
top: LAYOUT.legendTop,
itemGap: 28,
textStyle: {
color: PALETTE.ink,
fontSize: 14,
fontFamily: FONT,
},
data: SERIES.map((s) => s.name),
},
legend: standardLegend(SERIES.map((series) => series.name)),
grid: { left: 78, right: 56, top: LAYOUT.gridTop, bottom: 80 },
tooltip: {
trigger: 'axis',
+1 -6
View File
@@ -25,12 +25,7 @@ export function profitChart(rows, { width, height }) {
legend: {
...legendStyle,
top: LAYOUT.legendTop,
data: [
{ name: '营业收入', icon: 'rect' },
{ name: '毛利润', icon: 'rect' },
{ name: '归母净利润', icon: 'rect' },
{ name: '毛利率', icon: 'emptyCircle' },
],
data: ['营业收入', '毛利润', '归母净利润', '毛利率'],
},
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
+3 -2
View File
@@ -1,5 +1,5 @@
import {
PALETTE, FONT, titleStyle, footnote, watermarks,
PALETTE, FONT, titleStyle, footnote, watermarks, legendStyle,
LAYOUT, } from '../theme.js';
/**
@@ -44,7 +44,8 @@ export function regionChannelChart(rows, { width, height }) {
top: 18,
},
legend: {
data: channels.map(name => ({ name, icon: 'rect' })), // 全部堆叠柱状图
...legendStyle,
data: channels,
top: LAYOUT.legendTop,
itemGap: 32,
textStyle: { fontFamily: FONT, fontSize: 13 },
@@ -0,0 +1,117 @@
import {
FONT, PALETTE, categoryAxis, footnote, titleStyle, watermarks,
} from '../theme.js';
const REGION_ORDER = ['中国', '海外', '亚太', '美洲', '欧洲及其他'];
const CHANNEL_ORDER = ['线下渠道', '线上渠道', '批发及其他'];
function orderedValues(values, order) {
return [...values].sort((a, b) => {
const aIndex = order.indexOf(a);
const bIndex = order.indexOf(b);
return (aIndex === -1 ? order.length : aIndex) - (bIndex === -1 ? order.length : bIndex)
|| a.localeCompare(b, 'zh-CN');
});
}
function orderedPeriods(rows) {
return [...new Set(rows.map((row) => row.period))].sort((a, b) => {
const [yearA, halfA] = String(a).match(/(\d{4})H(\d)/).slice(1).map(Number);
const [yearB, halfB] = String(b).match(/(\d{4})H(\d)/).slice(1).map(Number);
return yearA - yearB || halfA - halfB;
});
}
/** 泡泡玛特区域 × 渠道同比增速热力图。 */
export function regionChannelYoyTimeseriesChart(rows, { width, height }) {
const periods = orderedPeriods(rows);
const regions = orderedValues(new Set(rows.map((row) => row.region)), REGION_ORDER);
const channels = orderedValues(new Set(rows.map((row) => row.channel)), CHANNEL_ORDER);
const dimensions = regions.flatMap((region) => channels.map((channel) => ({ region, channel })));
const periodIndex = new Map(periods.map((period, index) => [period, index]));
const dimensionIndex = new Map(dimensions.map(({ region, channel }, index) => [`${region}_${channel}`, index]));
const data = rows.map((row) => [
periodIndex.get(row.period),
dimensionIndex.get(`${row.region}_${row.channel}`),
row.yoy,
]);
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特区域渠道同比增速趋势',
subtext: '颜色越深代表同比变化幅度越大;空白表示当期未单列披露',
},
tooltip: {
position: 'top',
textStyle: { fontFamily: FONT },
formatter: (params) => {
const [periodIndexValue, dimensionIndexValue, value] = params.value;
const { region, channel } = dimensions[dimensionIndexValue];
const sign = value >= 0 ? '+' : '';
return `<b>${periods[periodIndexValue]}</b><br/>${region} · ${channel}<br/>同比:${sign}${value.toFixed(1)}%`;
},
},
grid: {
left: 178,
right: 42,
top: 112,
// 底部依次预留:横轴标签 → visualMap 控制器 → 数据来源。
bottom: 155,
},
xAxis: {
...categoryAxis,
data: periods,
axisLabel: {
...categoryAxis.axisLabel,
formatter: (value) => String(value).replace(/^20/, ''),
},
},
yAxis: {
...categoryAxis,
type: 'category',
inverse: true,
data: dimensions.map(({ region, channel }) => `${region} · ${channel}`),
axisLabel: {
...categoryAxis.axisLabel,
fontSize: 12,
margin: 12,
},
},
visualMap: {
min: -100,
max: 200,
calculable: true,
orient: 'horizontal',
left: 'center',
bottom: 64,
text: ['较高增长', '较低增长'],
textStyle: { color: PALETTE.grey, fontFamily: FONT, fontSize: 12 },
// 红色系代表较高增长;两端均采用浅色,保证数值标签清晰。
inRange: { color: ['#9cc4d0', '#f3eee6', PALETTE.coral] },
},
series: [{
name: '同比增速',
type: 'heatmap',
data,
label: {
show: true,
fontFamily: FONT,
fontSize: 11,
color: PALETTE.ink,
formatter: (params) => {
const value = params.value[2];
return `${value >= 0 ? '+' : ''}${value.toFixed(0)}%`;
},
},
emphasis: {
itemStyle: { borderColor: PALETTE.ink, borderWidth: 1 },
},
}],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特财报;早期海外为合并口径 | @思考的Joey'),
],
};
}
+3 -2
View File
@@ -1,5 +1,5 @@
import {
PALETTE, FONT, titleStyle, footnote, watermarks,
PALETTE, FONT, titleStyle, footnote, watermarks, legendStyle,
LAYOUT, } from '../theme.js';
/**
@@ -42,7 +42,8 @@ export function regionChannelYoyChart(rows, { width, height }) {
top: 18,
},
legend: {
data: channels.map(name => ({ name, icon: 'rect' })), // 全部柱状图
...legendStyle,
data: channels,
top: LAYOUT.legendTop,
itemGap: 32,
textStyle: { fontFamily: FONT, fontSize: 13 },
+58
View File
@@ -0,0 +1,58 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
lineSeries, footnote, watermarks, LAYOUT,
} from '../theme.js';
/** 泡泡玛特营运资本效率:存货、应收、应付与现金转换周期。 */
export function workingCapitalDaysChart(rows, { width, height }) {
const series = [
{ key: 'inventory_days', name: '存货周转天数', color: PALETTE.orange, width: 2.4 },
{ key: 'receivable_days', name: '应收周转天数', color: PALETTE.blue, width: 2.2 },
{ key: 'payable_days', name: '应付周转天数', color: PALETTE.purple, width: 2.2 },
{ key: 'cash_conversion_cycle', name: '现金转换周期', color: PALETTE.red, width: 3.2 },
];
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '泡泡玛特营运资本效率',
subtext: '现金转换周期 = 存货周转天数 + 应收周转天数 − 应付周转天数',
},
legend: { ...legendStyle, top: LAYOUT.legendTop, data: series.map((item) => item.name) },
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
trigger: 'axis',
textStyle: { fontFamily: FONT },
valueFormatter: (value) => `${value.toFixed(1)} 天`,
},
xAxis: { ...periodAxis, data: col(rows, 'period') },
yAxis: {
...axisCommon,
type: 'value',
name: '天数',
nameLocation: 'middle',
nameGap: 42,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
},
series: series.map((item) => ({
...lineSeries({
name: item.name,
data: col(rows, item.key),
color: item.color,
formatter: (p) => p.value.toFixed(0),
width: item.width,
}),
label: item.key === 'cash_conversion_cycle'
? { show: true, color: item.color, fontFamily: FONT, fontSize: 11, formatter: (p) => `${p.value.toFixed(0)}天` }
: { show: false },
z: item.key === 'cash_conversion_cycle' ? 10 : 1,
})),
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特财务指标(合并报表)| @思考的Joey'),
],
};
}
+1 -4
View File
@@ -15,10 +15,7 @@ export function twoLineChart(rows, { width, height }) {
legend: {
...legendStyle,
top: LAYOUT.legendTop,
data: ['毛利率', '非国际归母净利率'].map(name => ({
name,
icon: 'emptyCircle', // 全部折线图
})),
data: ['毛利率', '非国际归母净利率'],
},
grid: { left: 72, right: 48, top: LAYOUT.gridTop, bottom: 62 },
tooltip: {