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: {
+45 -25
View File
@@ -22,21 +22,31 @@ function validateManifest(value) {
throw new Error('manifest 缺少 companies');
}
const chartIds = new Set();
value.companies.forEach((company) => {
if (!company.id || !Array.isArray(company.tabs)) throw new Error('manifest 中存在无效公司配置');
if (!company.id || !Array.isArray(company.tabs) || !company.chartCatalog) {
throw new Error('manifest 中存在无效公司配置');
}
Object.entries(company.chartCatalog).forEach(([chartId, chart]) => {
if (!chartId || !getChartBuilder(chart.key)) {
throw new Error(`图表 ${chartId || '(空)'} 引用了未注册 key:${chart.key}`);
}
if (Boolean(chart.csv) === Boolean(chart.views)) {
throw new Error(`图表 ${chartId} 必须声明 csv 或 views 之一`);
}
if (chart.views && (!chart.defaultView || !chart.views[chart.defaultView])) {
throw new Error(`图表 ${chartId} 的 defaultView 无效`);
}
});
const tabIds = new Set();
company.tabs.forEach((tab) => {
if (!tab.id || tabIds.has(tab.id)) throw new Error(`公司 ${company.id} 的 tab id 重复或为空`);
if (!Array.isArray(tab.chartRefs)) throw new Error(`公司 ${company.id} 的 ${tab.id} 缺少 chartRefs`);
tabIds.add(tab.id);
(tab.charts || []).forEach((chart) => {
if (!chart.id || chartIds.has(chart.id)) throw new Error(`图表 id 重复或为空:${chart.id || '(空)'}`);
if (!getChartBuilder(chart.key)) throw new Error(`图表 ${chart.id} 引用了未注册 key:${chart.key}`);
if (Boolean(chart.csv) === Boolean(chart.views)) throw new Error(`图表 ${chart.id} 必须声明 csv 或 views 之一`);
if (chart.views && (!chart.defaultView || !chart.views[chart.defaultView])) {
throw new Error(`图表 ${chart.id} 的 defaultView 无效`);
}
chartIds.add(chart.id);
const refs = new Set();
tab.chartRefs.forEach((chartId) => {
if (!company.chartCatalog[chartId]) throw new Error(`栏目 ${tab.id} 引用了不存在的图表:${chartId}`);
if (refs.has(chartId)) throw new Error(`栏目 ${tab.id} 重复引用图表:${chartId}`);
refs.add(chartId);
});
});
});
@@ -50,6 +60,15 @@ function getTab(company, tabId) {
return company.tabs.find((tab) => tab.id === tabId) || company.tabs[0];
}
function chartsForTab(company, tab) {
return tab.chartRefs.map((chartId) => ({
...company.chartCatalog[chartId],
id: chartId,
instanceId: `${company.id}--${tab.id}--${chartId}`,
viewKey: `${company.id}--${chartId}`,
}));
}
function parseHash() {
const [path = '', query = ''] = window.location.hash.replace(/^#\/?/, '').split('?');
const [companyId, tabId, chartId] = path.split('/').filter(Boolean).map(decodeURIComponent);
@@ -60,11 +79,11 @@ function parseHash() {
function normalizeState(nextState) {
const company = getCompany(nextState.companyId);
const tab = getTab(company, nextState.tabId);
const chart = tab.charts.find((item) => item.id === nextState.chartId);
const chart = chartsForTab(company, tab).find((item) => item.id === nextState.chartId);
const view = chart?.views && chart.views[nextState.view]
? nextState.view
: chart?.defaultView;
if (chart?.views && view) activeViews.set(chart.id, view);
if (chart?.views && view) activeViews.set(chart.viewKey, view);
return { companyId: company.id, tabId: tab.id, chartId: chart?.id, view };
}
@@ -83,12 +102,12 @@ function disposeAll() {
function chartSource(chart) {
if (!chart.views) return chart.csv;
const view = activeViews.get(chart.id) || chart.defaultView;
const view = activeViews.get(chart.viewKey) || chart.defaultView;
return chart.views[view];
}
async function renderChart(chart, version) {
const el = document.getElementById(`chart-${chart.id}`);
const el = document.getElementById(`chart-${chart.instanceId}`);
if (!el) return;
const source = chartSource(chart);
@@ -97,11 +116,11 @@ async function renderChart(chart, version) {
if (version !== renderVersion || !document.body.contains(el)) return;
const instance = echarts.init(el, null, { renderer: 'canvas' });
instances.set(chart.id, instance);
instances.set(chart.instanceId, instance);
const { width, height } = el.getBoundingClientRect();
instance.setOption(getChartBuilder(chart.key)(rows, { width, height }), true);
} catch (error) {
if (version === renderVersion) showChartError(chart.id, error.message);
if (version === renderVersion) showChartError(chart.instanceId, error.message);
console.error(`渲染图表失败:${chart.id}`, error);
}
}
@@ -125,16 +144,17 @@ function updateHash(nextState) {
window.location.hash = hash;
}
function selectAnchor(chartId) {
state = { ...state, chartId, view: activeViews.get(chartId) };
function selectAnchor(chart) {
state = { ...state, chartId: chart.id, view: activeViews.get(chart.viewKey) };
history.replaceState(null, '', serializeHash(state));
document.getElementById(`card-${chartId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
document.getElementById(`card-${chart.instanceId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function renderCurrent() {
state = normalizeState(state);
const company = getCompany(state.companyId);
const tab = getTab(company, state.tabId);
const charts = chartsForTab(company, tab);
const version = ++renderVersion;
disposeAll();
@@ -144,15 +164,15 @@ function renderCurrent() {
renderTabs(shell.tabs, company.tabs, tab.id, (tabId) => {
updateHash({ companyId: company.id, tabId });
});
renderCards(shell.cards, tab.charts || [], activeViews, {
renderCards(shell.cards, charts, activeViews, {
onExport: exportPNG,
onSelectView: (chartId, view) => {
activeViews.set(chartId, view);
updateHash({ companyId: company.id, tabId: tab.id, chartId, view });
onSelectView: (chart, view) => {
activeViews.set(chart.viewKey, view);
updateHash({ companyId: company.id, tabId: tab.id, chartId: chart.id, view });
},
});
renderAnchors(shell.chartNav, tab.charts || [], selectAnchor);
Promise.all((tab.charts || []).map((chart) => renderChart(chart, version)));
renderAnchors(shell.chartNav, charts, selectAnchor);
Promise.all(charts.map((chart) => renderChart(chart, version)));
}
async function start() {
+52 -4
View File
@@ -27,6 +27,27 @@ export const PALETTE = {
axisLine: '#d0d0d0',
};
/**
* 试行语义色:柱线组合图中,柱采用低饱和辅助色,折线保留单一强调色。
* 待页面视觉确认后再固化为新增图表的正式规范。
*/
export const FINANCIAL_COLORS = {
operatingCashFlow: '#6E9A83',
investingCashFlow: '#7897B2',
financingCashFlow: '#B18A66',
netCashIncrease: '#A8465C',
netProfit: '#C9885C',
cashConversion: '#6B4A6C',
};
export const CHANNEL_COLORS = {
retail: '#597A9B',
online: '#C87368',
robotStore: '#98A77A',
wholesale: '#BB9A5C',
revenueGrowth: '#775D8B',
};
/** 坐标轴通用样式:只留浅色横向网格,去掉轴线的存在感 */
export const axisCommon = {
axisLine: { show: false },
@@ -104,13 +125,14 @@ export const LAYOUT = {
gridTop: 145, // 绘图区顶部边距(图例到绘图区约 60px)
};
// 统一图例图标宽度,同时保留 ECharts 按系列类型生成的形状。
export const LEGEND_ITEM_WIDTH = 20;
export const legendStyle = {
top: 62, // ⚠️ 已废弃:请改用 LAYOUT.legendTop 或 standardLegend()
itemGap: 26,
itemWidth: 18,
// ❌ 删除 icon: 'roundRect'
// 原因:它会覆盖 ECharts 按系列类型自动生成的图例样式(折线图的"短线+空心圆")
// 现在让每个图表按需设置,或使用 standardLegend() 获得正确默认值
itemWidth: LEGEND_ITEM_WIDTH,
// 不设置 icon:柱状图自动使用矩形,折线图自动使用“短线 + 空心圆”。
textStyle: {
color: PALETTE.ink,
fontSize: 14,
@@ -139,6 +161,7 @@ export function labelStyle(color, position = 'top') {
export function standardLegend(data, options = {}) {
return {
top: LAYOUT.legendTop,
itemWidth: LEGEND_ITEM_WIDTH,
itemGap: options.itemGap ?? 28,
textStyle: {
color: PALETTE.ink,
@@ -176,6 +199,31 @@ export function lineSeries({ name, data, color, labelPosition = 'top', formatter
};
}
/**
* 柱线组合图的折线标签:正值放在点上方、负值放在点下方;
* 若与同期间柱值接近,则加大距离,避免文字压在柱体端点附近。
*/
export function lineDataWithSafeLabels(rows, { lineKey, barKeys, distance = 8, closeDistance = 14 }) {
const values = rows.map((row) => row[lineKey]).filter(Number.isFinite);
const range = Math.max(...values) - Math.min(...values) || 1;
const threshold = range * 0.06;
return rows.map((row) => {
const value = row[lineKey];
const nearestBarGap = Math.min(...barKeys
.map((key) => row[key])
.filter(Number.isFinite)
.map((barValue) => Math.abs(value - barValue)));
return {
value,
label: {
position: value < 0 ? 'bottom' : 'top',
distance: nearestBarGap <= threshold ? closeDistance : distance,
},
};
});
}
/**
* 柱状图系列标准配置
* 强制显示所有标签(overflow: 'none'),避免小柱子标签被 ECharts 自动隐藏
+9 -10
View File
@@ -16,10 +16,9 @@ function button(label, { active = false, onClick, className = '' } = {}) {
export function createAppShell(root, { onReload }) {
root.replaceChildren();
const header = element('header', 'app-header');
const brand = element('div', 'app-brand');
brand.append(element('h1', '', '财报图表'), element('p', '', '选择公司与分析维度,数据更新后可重新加载。'));
header.append(brand, button('重新加载数据', { onClick: onReload, className: 'reload-button' }));
const reloadButton = button('↻', { onClick: onReload, className: 'reload-button' });
reloadButton.title = '重新加载数据';
reloadButton.setAttribute('aria-label', '重新加载数据');
const layout = element('div', 'app-layout');
const companyNav = element('aside', 'sidebar company-sidebar');
@@ -29,7 +28,7 @@ export function createAppShell(root, { onReload }) {
content.append(tabs, cards);
const chartNav = element('aside', 'sidebar chart-sidebar');
layout.append(companyNav, content, chartNav);
root.append(header, layout);
root.append(layout, reloadButton);
return { companyNav, tabs, cards, chartNav };
}
@@ -67,13 +66,13 @@ export function renderCards(container, charts, activeViews, { onExport, onSelect
charts.forEach((chart) => {
const card = element('section', 'card');
card.id = `card-${chart.id}`;
card.id = `card-${chart.instanceId}`;
const bar = element('div', 'card-bar');
const heading = element('div', 'card-heading');
heading.append(element('h2', 'card-title', chart.title));
const viewNames = Object.keys(chart.views || {});
const activeView = activeViews.get(chart.id) || chart.defaultView || viewNames[0];
const activeView = activeViews.get(chart.viewKey) || chart.defaultView || viewNames[0];
const source = chart.views ? chart.views[activeView] : chart.csv;
heading.append(element('span', 'src', source));
bar.append(heading);
@@ -84,7 +83,7 @@ export function renderCards(container, charts, activeViews, { onExport, onSelect
viewNames.forEach((view) => {
switcher.append(button(labels[view] || view, {
active: view === activeView,
onClick: () => onSelectView(chart.id, view),
onClick: () => onSelectView(chart, view),
className: 'view-button',
}));
});
@@ -92,11 +91,11 @@ export function renderCards(container, charts, activeViews, { onExport, onSelect
}
bar.append(button('导出 PNG', {
onClick: () => onExport(chart.id, chart.filename || chart.title),
onClick: () => onExport(chart.instanceId, chart.filename || chart.title),
className: 'export-button',
}));
const chartEl = element('div', 'chart');
chartEl.id = `chart-${chart.id}`;
chartEl.id = `chart-${chart.instanceId}`;
chartEl.style.height = `${chart.height || 680}px`;
card.append(bar, chartEl);
container.append(card);