Refactor financial chart implementation
This commit is contained in:
+1
-4
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export function ppmtCoreProfitChart(rows, { width, height }) {
|
||||
subtext: '核心利润 = 毛利 - 销售费用 - 管理费用,单位:亿元',
|
||||
},
|
||||
legend: {
|
||||
...legendStyle,
|
||||
top: LAYOUT.legendTop,
|
||||
itemGap: 32,
|
||||
textStyle: {
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user