feat: 优化泡泡玛特的图表
This commit is contained in:
@@ -1,16 +0,0 @@
|
|||||||
region,channel,value
|
|
||||||
中国,线下渠道,68.69
|
|
||||||
中国,线上渠道,47.79
|
|
||||||
中国,批发及其他,5.53
|
|
||||||
海外,线下渠道,31.94
|
|
||||||
海外,线上渠道,14.34
|
|
||||||
海外,批发及其他,3.45
|
|
||||||
亚太,线下渠道,17.73
|
|
||||||
亚太,线上渠道,6.44
|
|
||||||
亚太,批发及其他,1.59
|
|
||||||
美洲,线下渠道,10.07
|
|
||||||
美洲,线上渠道,7.22
|
|
||||||
美洲,批发及其他,1.62
|
|
||||||
欧洲及其他,线下渠道,4.14
|
|
||||||
欧洲及其他,线上渠道,0.68
|
|
||||||
欧洲及其他,批发及其他,0.24
|
|
||||||
|
@@ -8,7 +8,6 @@ import { profitChart } from './ppmt_profit.js';
|
|||||||
import { ipChart } from './ppmt_ip.js';
|
import { ipChart } from './ppmt_ip.js';
|
||||||
import { categoryChart } from './ppmt_category.js';
|
import { categoryChart } from './ppmt_category.js';
|
||||||
import { sankeyChart } from './ppmt_sankey.js';
|
import { sankeyChart } from './ppmt_sankey.js';
|
||||||
import { regionChannelChart } from './ppmt_region_channel.js';
|
|
||||||
import { regionChannelYoyChart } from './ppmt_region_yoy.js';
|
import { regionChannelYoyChart } from './ppmt_region_yoy.js';
|
||||||
import { regionChannelYoyTimeseriesChart } from './ppmt_region_channel_yoy_timeseries.js';
|
import { regionChannelYoyTimeseriesChart } from './ppmt_region_channel_yoy_timeseries.js';
|
||||||
import { ppmtGrowthChart } from './ppmt_growth.js';
|
import { ppmtGrowthChart } from './ppmt_growth.js';
|
||||||
@@ -60,7 +59,6 @@ export const chartRegistry = {
|
|||||||
'ppmt-ip': ipChart,
|
'ppmt-ip': ipChart,
|
||||||
'ppmt-category': categoryChart,
|
'ppmt-category': categoryChart,
|
||||||
'ppmt-sankey': sankeyChart,
|
'ppmt-sankey': sankeyChart,
|
||||||
'ppmt-region-channel': regionChannelChart,
|
|
||||||
'ppmt-region-yoy': regionChannelYoyChart,
|
'ppmt-region-yoy': regionChannelYoyChart,
|
||||||
'ppmt-region-yoy-timeseries': regionChannelYoyTimeseriesChart,
|
'ppmt-region-yoy-timeseries': regionChannelYoyTimeseriesChart,
|
||||||
'ppmt-growth': ppmtGrowthChart,
|
'ppmt-growth': ppmtGrowthChart,
|
||||||
|
|||||||
+54
-35
@@ -1,16 +1,45 @@
|
|||||||
import { col } from '../csv.js';
|
import { col } from '../csv.js';
|
||||||
import {
|
import {
|
||||||
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
|
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
|
||||||
financialViewLabel,
|
financialViewLabel, niceAxisMax, fanTinyLabelOffsets, stackedSharePoint,
|
||||||
lineSeries, footnote, watermarks, LAYOUT,
|
lineSeries, footnote, watermarks, LAYOUT,
|
||||||
} from '../theme.js';
|
} from '../theme.js';
|
||||||
|
|
||||||
|
const KEYS = ['cash_like', 'receivables', 'investment', 'operating_assets'];
|
||||||
|
const NAMES = {
|
||||||
|
cash_like: '类现金',
|
||||||
|
receivables: '应收账款',
|
||||||
|
investment: '投资资产',
|
||||||
|
operating_assets: '经营类资产',
|
||||||
|
};
|
||||||
|
const COLORS = {
|
||||||
|
cash_like: PALETTE.blue,
|
||||||
|
receivables: PALETTE.orange,
|
||||||
|
investment: PALETTE.purple,
|
||||||
|
operating_assets: PALETTE.sage,
|
||||||
|
};
|
||||||
|
// 段内标签文字色:浅色段(经营类资产灰绿)用深字,深色段用白字
|
||||||
|
const INSIDE_LABEL_COLORS = {
|
||||||
|
cash_like: '#fff',
|
||||||
|
receivables: '#fff',
|
||||||
|
investment: '#fff',
|
||||||
|
operating_assets: PALETTE.ink,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 泡泡玛特资产结构演变图
|
* 泡泡玛特资产结构演变图
|
||||||
* 堆叠柱状图展示资产配置,折线图展示总资产增长
|
* 堆叠柱状图展示资产配置(每段标数值),折线图展示总资产增长
|
||||||
*/
|
*/
|
||||||
export function assetsChart(rows, { width, height, view = 'all' }) {
|
export function assetsChart(rows, { width, height, view = 'all' }) {
|
||||||
const periods = col(rows, 'period');
|
const periods = col(rows, 'period');
|
||||||
|
const totalMax = Math.max(...rows.map((r) => r.total_assets));
|
||||||
|
// 轴上限随数据走(2025H2 总资产已 321 亿),矮段像素判定才有确定基准
|
||||||
|
const axisMax = niceAxisMax(totalMax);
|
||||||
|
const interval = axisMax % 7 === 0 ? axisMax / 7 : axisMax / 5;
|
||||||
|
// 绘图区像素高:画布高 − grid top 145 − grid bottom 90
|
||||||
|
const pxPerUnit = (height - LAYOUT.gridTop - 90) / axisMax;
|
||||||
|
// 早期年份应收/投资不足 1 亿,矮段标签外置到柱体右侧扇形排开
|
||||||
|
const rowOffsets = fanTinyLabelOffsets(rows, KEYS, pxPerUnit);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
@@ -22,7 +51,7 @@ export function assetsChart(rows, { width, height, view = 'all' }) {
|
|||||||
legend: {
|
legend: {
|
||||||
...legendStyle,
|
...legendStyle,
|
||||||
top: LAYOUT.legendTop,
|
top: LAYOUT.legendTop,
|
||||||
data: ['类现金', '应收账款', '投资资产', '经营类资产', '资产总计'],
|
data: [...KEYS.map((key) => NAMES[key]), '资产总计'],
|
||||||
},
|
},
|
||||||
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
|
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
@@ -40,8 +69,8 @@ export function assetsChart(rows, { width, height, view = 'all' }) {
|
|||||||
nameGap: 52,
|
nameGap: 52,
|
||||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 350,
|
max: axisMax,
|
||||||
interval: 50,
|
interval,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...axisCommon,
|
...axisCommon,
|
||||||
@@ -52,44 +81,34 @@ export function assetsChart(rows, { width, height, view = 'all' }) {
|
|||||||
nameRotate: -90,
|
nameRotate: -90,
|
||||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 350,
|
max: axisMax,
|
||||||
interval: 50,
|
interval,
|
||||||
splitLine: { show: false },
|
splitLine: { show: false },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
series: [
|
series: [
|
||||||
{
|
...KEYS.map((key, ci) => ({
|
||||||
name: '类现金',
|
name: NAMES[key],
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
stack: 'assets',
|
stack: 'assets',
|
||||||
data: col(rows, 'cash_like'),
|
// 每段都标数值:矮段塞不进字的,外置到柱体右侧(机制同渠道金额图)
|
||||||
itemStyle: { color: PALETTE.blue },
|
data: rows.map((row, ri) => stackedSharePoint(row[key], {
|
||||||
emphasis: { focus: 'series' },
|
pxPerUnit,
|
||||||
},
|
labelColor: INSIDE_LABEL_COLORS[key],
|
||||||
{
|
dy: rowOffsets[ri][ci] ?? 0,
|
||||||
name: '应收账款',
|
})),
|
||||||
type: 'bar',
|
itemStyle: { color: COLORS[key] },
|
||||||
stack: 'assets',
|
|
||||||
data: col(rows, 'receivables'),
|
|
||||||
itemStyle: { color: PALETTE.orange },
|
|
||||||
emphasis: { focus: 'series' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '投资资产',
|
|
||||||
type: 'bar',
|
|
||||||
stack: 'assets',
|
|
||||||
data: col(rows, 'investment'),
|
|
||||||
itemStyle: { color: PALETTE.purple },
|
|
||||||
emphasis: { focus: 'series' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '经营类资产',
|
|
||||||
type: 'bar',
|
|
||||||
stack: 'assets',
|
|
||||||
data: col(rows, 'operating_assets'),
|
|
||||||
itemStyle: { color: PALETTE.sage },
|
|
||||||
emphasis: { focus: 'series' },
|
emphasis: { focus: 'series' },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'inside',
|
||||||
|
color: INSIDE_LABEL_COLORS[key],
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: FONT,
|
||||||
|
overflow: 'none',
|
||||||
|
formatter: (p) => `${p.value.toFixed(1)}`,
|
||||||
},
|
},
|
||||||
|
})),
|
||||||
{
|
{
|
||||||
...lineSeries({
|
...lineSeries({
|
||||||
name: '资产总计',
|
name: '资产总计',
|
||||||
|
|||||||
+122
-69
@@ -1,49 +1,113 @@
|
|||||||
import { col } from '../csv.js';
|
import { col } from '../csv.js';
|
||||||
import {
|
import {
|
||||||
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
|
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
|
||||||
financialViewDisclosure, financialViewLabel,
|
financialViewDisclosure, financialViewLabel, niceAxisMax,
|
||||||
footnote, watermarks, LAYOUT,
|
fanTinyLabelOffsets, stackedSharePoint,
|
||||||
|
lineSeries, footnote, watermarks, LAYOUT,
|
||||||
} from '../theme.js';
|
} from '../theme.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 泡泡玛特品类收入结构图
|
* 泡泡玛特品类收入结构图
|
||||||
* 堆叠柱状图展示各品类的收入占比和增长
|
* 堆叠柱展示各品类收入;折线(右轴)展示各品类收入同比,折线与柱同色便于对应。
|
||||||
|
* 同比为衍生指标:当期 ÷ 上年同期 − 1(H2 对 H2 单半年、FY 对 FY 累计,同期对同期),
|
||||||
|
* 2024 年各期无上年基数,不计算。
|
||||||
*/
|
*/
|
||||||
|
// 同比折线用柱色的同色系深色变体:压在柱体上能区分开,又靠色相保持与柱的对应
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ name: '毛绒', key: 'plush', color: PALETTE.coral, lineColor: PALETTE.red },
|
||||||
|
{ name: '手办', key: 'figure', color: PALETTE.blue, lineColor: PALETTE.navy },
|
||||||
|
// 色板没有深紫,按 MEGA 紫加深
|
||||||
|
{ name: 'MEGA', key: 'mega', color: PALETTE.purple, lineColor: '#5E2A79' },
|
||||||
|
{ name: '衍生品及其他', key: 'other', color: PALETTE.sage, lineColor: PALETTE.green },
|
||||||
|
];
|
||||||
|
|
||||||
export function categoryChart(rows, { width, height, view = 'all' }) {
|
export function categoryChart(rows, { width, height, view = 'all' }) {
|
||||||
// 过滤掉表头行("品类"那一行)
|
// 过滤掉表头行("品类"那一行)
|
||||||
const dataRows = rows.filter(r => r.period && r.period !== '品类');
|
const dataRows = rows.filter(r => r.period && r.period !== '品类');
|
||||||
const periods = col(dataRows, 'period');
|
const periods = col(dataRows, 'period');
|
||||||
|
const num = (r, k) => (Number.isFinite(r[k]) ? r[k] : null);
|
||||||
|
|
||||||
|
// 上年同期的期间下标(H1↔H1、H2↔H2、FY↔FY),无则 -1
|
||||||
|
const priorIndex = periods.map((p) => {
|
||||||
|
if (!/^20\d{2}(H1|H2|FY)$/.test(p)) return -1;
|
||||||
|
const prev = p.replace(/^20(\d{2})/, (_, yy) => `20${String(Number(yy) - 1).padStart(2, '0')}`);
|
||||||
|
return periods.indexOf(prev);
|
||||||
|
});
|
||||||
|
const yoyByCat = Object.fromEntries(CATEGORIES.map((c) => [
|
||||||
|
c.key,
|
||||||
|
periods.map((_, i) => {
|
||||||
|
const j = priorIndex[i];
|
||||||
|
const cur = num(dataRows[i], c.key);
|
||||||
|
const prev = j >= 0 ? num(dataRows[j], c.key) : null;
|
||||||
|
if (cur == null || prev == null || prev === 0) return null;
|
||||||
|
return (cur / prev - 1) * 100;
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
const yoyValues = CATEGORIES.flatMap((c) => yoyByCat[c.key]).filter(Number.isFinite);
|
||||||
|
const yoyMax = Math.max(100, Math.ceil(Math.max(...yoyValues) / 100) * 100);
|
||||||
|
const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 100) * 100);
|
||||||
|
// 右轴固定整百刻度:范围 ≤500 时 100% 一步,否则 200% 一步,刻度数控制在 7 以内
|
||||||
|
const yoyInterval = yoyMax - yoyMin <= 500 ? 100 : 200;
|
||||||
|
|
||||||
|
// 多折线标签避让(同 ppmt_margins labeledData):比较上下间隙,朝更大的一侧放
|
||||||
|
const yoyLabelPos = periods.map((_, i) => {
|
||||||
|
const ranked = CATEGORIES
|
||||||
|
.map((c) => yoyByCat[c.key][i])
|
||||||
|
.filter(Number.isFinite)
|
||||||
|
.sort((a, b) => b - a);
|
||||||
|
return Object.fromEntries(CATEGORIES.map((c) => {
|
||||||
|
const v = yoyByCat[c.key][i];
|
||||||
|
if (!Number.isFinite(v)) return [c.key, 'top'];
|
||||||
|
const rank = ranked.indexOf(v);
|
||||||
|
const gapUp = rank > 0 ? ranked[rank - 1] - v : Infinity;
|
||||||
|
const gapDown = rank < ranked.length - 1 ? v - ranked[rank + 1] : Infinity;
|
||||||
|
return [c.key, gapUp >= gapDown ? 'top' : 'bottom'];
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const totals = periods.map((_, i) => CATEGORIES.reduce((s, c) => s + (num(dataRows[i], c.key) ?? 0), 0));
|
||||||
|
// 轴上限随数据走:2025FY 合计已 371.2 亿,写死 250 会把柱子顶出绘图区
|
||||||
|
const axisMax = niceAxisMax(Math.max(...totals));
|
||||||
|
const amountInterval = axisMax % 4 === 0 ? axisMax / 4 : axisMax / 5;
|
||||||
|
// 绘图区像素高:画布高 − grid top 145 − grid bottom 90
|
||||||
|
const pxPerUnit = (height - LAYOUT.gridTop - 90) / axisMax;
|
||||||
|
const rowOffsets = fanTinyLabelOffsets(dataRows, CATEGORIES.map((c) => c.key), pxPerUnit);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
title: {
|
title: {
|
||||||
...titleStyle,
|
...titleStyle,
|
||||||
text: `泡泡玛特品类收入结构(${financialViewLabel(view)})`,
|
text: `泡泡玛特品类收入结构(${financialViewLabel(view)})`,
|
||||||
subtext: ['单位:亿元;H2为下半年单期', financialViewDisclosure(view)].filter(Boolean).join('\n'),
|
subtext: ['柱为收入(亿元,左轴);折线为各品类收入同比(右轴);H2为下半年单期',
|
||||||
|
financialViewDisclosure(view)].filter(Boolean).join('\n'),
|
||||||
},
|
},
|
||||||
|
// 图例柱与同比线交错排列,同品类的两项相邻
|
||||||
legend: {
|
legend: {
|
||||||
...legendStyle,
|
...legendStyle,
|
||||||
top: LAYOUT.legendTop,
|
top: LAYOUT.legendTop,
|
||||||
data: ['毛绒', '手办', 'MEGA', '衍生品及其他'],
|
data: CATEGORIES.flatMap((c) => [c.name, `${c.name}同比`]),
|
||||||
},
|
},
|
||||||
grid: { left: 78, right: 56, top: LAYOUT.gridTop, bottom: 90 },
|
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
axisPointer: { type: 'shadow' },
|
axisPointer: { type: 'shadow' },
|
||||||
textStyle: { fontFamily: FONT },
|
textStyle: { fontFamily: FONT },
|
||||||
formatter: (params) => {
|
formatter: (params) => {
|
||||||
|
const bars = params.filter((p) => p.seriesType === 'bar' && Number.isFinite(p.value));
|
||||||
|
const lines = params.filter((p) => p.seriesType === 'line' && Number.isFinite(p.value));
|
||||||
|
const total = bars.reduce((s, p) => s + p.value, 0);
|
||||||
let result = `<b>${params[0].axisValue}</b><br/>`;
|
let result = `<b>${params[0].axisValue}</b><br/>`;
|
||||||
let total = 0;
|
bars.forEach((p) => { result += `${p.marker}${p.seriesName}: ${p.value.toFixed(2)} 亿元<br/>`; });
|
||||||
params.forEach(p => {
|
result += `<b>合计: ${total.toFixed(2)} 亿元</b><br/>`;
|
||||||
total += p.value;
|
lines.forEach((p) => {
|
||||||
result += `${p.marker}${p.seriesName}: ${p.value.toFixed(2)} 亿元<br/>`;
|
result += `${p.marker}${p.seriesName}: ${p.value > 0 ? '+' : ''}${p.value.toFixed(1)}%<br/>`;
|
||||||
});
|
});
|
||||||
result += `<b>合计: ${total.toFixed(2)} 亿元</b>`;
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
xAxis: { ...standalonePeriodAxis, data: periods },
|
xAxis: { ...standalonePeriodAxis, data: periods },
|
||||||
yAxis: {
|
yAxis: [
|
||||||
|
{
|
||||||
...axisCommon,
|
...axisCommon,
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: '收入(亿元)',
|
name: '收入(亿元)',
|
||||||
@@ -51,16 +115,36 @@ export function categoryChart(rows, { width, height, view = 'all' }) {
|
|||||||
nameGap: 52,
|
nameGap: 52,
|
||||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 250,
|
max: axisMax,
|
||||||
interval: 50,
|
interval: amountInterval,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '收入同比(%)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 56,
|
||||||
|
nameRotate: -90,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: yoyMin,
|
||||||
|
max: yoyMax,
|
||||||
|
interval: yoyInterval,
|
||||||
|
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
||||||
|
splitLine: { show: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
series: [
|
series: [
|
||||||
{
|
...CATEGORIES.map((c, ci) => ({
|
||||||
name: '毛绒',
|
name: c.name,
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
stack: 'category',
|
stack: 'category',
|
||||||
data: col(dataRows, 'plush'),
|
// 每段都标数值:矮段外置到柱体右侧,机制同渠道金额图
|
||||||
itemStyle: { color: PALETTE.coral },
|
data: dataRows.map((row, ri) => stackedSharePoint(row[c.key], {
|
||||||
|
pxPerUnit,
|
||||||
|
labelColor: '#fff',
|
||||||
|
dy: rowOffsets[ri][ci] ?? 0,
|
||||||
|
})),
|
||||||
|
itemStyle: { color: c.color },
|
||||||
emphasis: { focus: 'series' },
|
emphasis: { focus: 'series' },
|
||||||
label: {
|
label: {
|
||||||
show: true,
|
show: true,
|
||||||
@@ -68,61 +152,30 @@ export function categoryChart(rows, { width, height, view = 'all' }) {
|
|||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: FONT,
|
fontFamily: FONT,
|
||||||
formatter: (p) => (p.value > 15 ? p.value.toFixed(1) : ''),
|
overflow: 'none',
|
||||||
},
|
formatter: (p) => `${p.value.toFixed(1)}`,
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '手办',
|
|
||||||
type: 'bar',
|
|
||||||
stack: 'category',
|
|
||||||
data: col(dataRows, 'figure'),
|
|
||||||
itemStyle: { color: PALETTE.blue },
|
|
||||||
emphasis: { focus: 'series' },
|
|
||||||
label: {
|
|
||||||
show: true,
|
|
||||||
position: 'inside',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: FONT,
|
|
||||||
formatter: (p) => (p.value > 15 ? p.value.toFixed(1) : ''),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'MEGA',
|
|
||||||
type: 'bar',
|
|
||||||
stack: 'category',
|
|
||||||
data: col(dataRows, 'mega'),
|
|
||||||
itemStyle: { color: PALETTE.purple },
|
|
||||||
emphasis: { focus: 'series' },
|
|
||||||
label: {
|
|
||||||
show: true,
|
|
||||||
position: 'inside',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: FONT,
|
|
||||||
formatter: (p) => (p.value > 8 ? p.value.toFixed(1) : ''),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '衍生品及其他',
|
|
||||||
type: 'bar',
|
|
||||||
stack: 'category',
|
|
||||||
data: col(dataRows, 'other'),
|
|
||||||
itemStyle: { color: PALETTE.sage },
|
|
||||||
emphasis: { focus: 'series' },
|
|
||||||
label: {
|
|
||||||
show: true,
|
|
||||||
position: 'inside',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: FONT,
|
|
||||||
formatter: (p) => (p.value > 8 ? p.value.toFixed(1) : ''),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
})),
|
||||||
|
...CATEGORIES.map((c) => ({
|
||||||
|
...lineSeries({
|
||||||
|
name: `${c.name}同比`,
|
||||||
|
data: yoyByCat[c.key],
|
||||||
|
color: c.lineColor,
|
||||||
|
formatter: (p) => `${p.value.toFixed(1)}%`,
|
||||||
|
width: 2.5,
|
||||||
|
}),
|
||||||
|
yAxisIndex: 1,
|
||||||
|
z: 10,
|
||||||
|
// label.position 不接受函数,逐点覆盖(避让规则见 yoyLabelPos)
|
||||||
|
data: yoyByCat[c.key].map((v, i) => (Number.isFinite(v)
|
||||||
|
? { value: v, label: { position: yoyLabelPos[i][c.key], distance: 10 } }
|
||||||
|
: null)),
|
||||||
|
labelLayout: { hideOverlap: true },
|
||||||
|
})),
|
||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特财报 | @Joey知行记'),
|
footnote('数据来源:泡泡玛特财报;同比=当期÷上年同期−1\n2024年各期无上年基数,不计算同比 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { col } from '../csv.js';
|
import { col } from '../csv.js';
|
||||||
import {
|
import {
|
||||||
CHANNEL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
|
CHANNEL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
|
||||||
financialViewDisclosure, financialViewLabel,
|
financialViewDisclosure, financialViewLabel, niceAxisMax,
|
||||||
lineSeries, footnote, watermarks, LAYOUT,
|
lineSeries, footnote, watermarks, LAYOUT,
|
||||||
} from '../theme.js';
|
} from '../theme.js';
|
||||||
|
|
||||||
@@ -23,14 +23,12 @@ const INSIDE_LABEL_COLORS = {
|
|||||||
|
|
||||||
const num = (value) => (Number.isFinite(value) ? value : null);
|
const num = (value) => (Number.isFinite(value) ? value : null);
|
||||||
|
|
||||||
// 轴上限取整齐刻度,柱高比例确定后,段标签的段内/外置可按像素判断
|
// 轴上限按数据最大值取整齐刻度,柱高比例确定后,段标签的段内/外置可按像素判断
|
||||||
const NICE_MAX = [20, 25, 50, 60, 80, 100, 120, 150, 200, 250, 300, 400, 500, 600, 800, 1000];
|
|
||||||
const MIN_INSIDE_PX = 13; // 低于约 13px 的段放不下 10px 段内标签
|
const MIN_INSIDE_PX = 13; // 低于约 13px 的段放不下 10px 段内标签
|
||||||
|
|
||||||
export function channelRevenueChart(rows, { width, height, view = 'all' }) {
|
export function channelRevenueChart(rows, { width, height, view = 'all' }) {
|
||||||
const totalMax = Math.max(...rows.map((row) => num(row.total_revenue) || 0));
|
const totalMax = Math.max(...rows.map((row) => num(row.total_revenue) || 0));
|
||||||
const axisMax = NICE_MAX.find((max) => max >= totalMax * 1.02)
|
const axisMax = niceAxisMax(totalMax);
|
||||||
?? Math.ceil((totalMax * 1.2) / 100) * 100;
|
|
||||||
const yoyValues = col(rows, 'revenue_yoy').filter(Number.isFinite);
|
const yoyValues = col(rows, 'revenue_yoy').filter(Number.isFinite);
|
||||||
// 全部/半年视图存在 2022H2 的负增长,右轴需包含负区间,避免折线被 0 轴裁切。
|
// 全部/半年视图存在 2022H2 的负增长,右轴需包含负区间,避免折线被 0 轴裁切。
|
||||||
const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 50) * 50);
|
const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 50) * 50);
|
||||||
|
|||||||
@@ -1,12 +1,49 @@
|
|||||||
import { col } from '../csv.js';
|
import { col } from '../csv.js';
|
||||||
import {
|
import {
|
||||||
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
|
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
|
||||||
financialViewDisclosure, financialViewLabel,
|
financialViewDisclosure, financialViewLabel, niceAxisMax, stackedSharePoint,
|
||||||
lineSeries, footnote, watermarks, LAYOUT,
|
lineSeries, footnote, watermarks, LAYOUT,
|
||||||
} from '../theme.js';
|
} from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 混合正负堆叠的矮段标签外置偏移。
|
||||||
|
* ECharts 对负值段自 0 向下堆叠,theme 的 fanTinyLabelOffsets 按全正累计
|
||||||
|
* 算不出正确中心,这里按符号分两摞累计(屏幕坐标 y 向下为正),
|
||||||
|
* 矮段(塞不进段内文字的)标签扇形排开在柱体右侧。
|
||||||
|
*/
|
||||||
|
function expenseFanOffsets(rows, keys, pxPerUnit, { minInsidePx = 15, gap = 13 } = {}) {
|
||||||
|
return rows.map((row) => {
|
||||||
|
let posCum = 0;
|
||||||
|
let negCum = 0;
|
||||||
|
const centers = {};
|
||||||
|
keys.forEach((k) => {
|
||||||
|
const v = Number.isFinite(row[k]) ? row[k] : 0;
|
||||||
|
const mid = v >= 0 ? posCum + v / 2 : negCum + v / 2;
|
||||||
|
centers[k] = -mid * pxPerUnit;
|
||||||
|
if (v >= 0) posCum += v; else negCum += v;
|
||||||
|
});
|
||||||
|
const tiny = keys.filter((k) => Number.isFinite(row[k]) && row[k] !== 0
|
||||||
|
&& Math.abs(row[k]) * pxPerUnit < minInsidePx);
|
||||||
|
if (!tiny.length) return {};
|
||||||
|
const ys = tiny.map((k) => centers[k]);
|
||||||
|
for (let i = 1; i < ys.length; i += 1) ys[i] = Math.max(ys[i], ys[i - 1] + gap);
|
||||||
|
const offsets = {};
|
||||||
|
tiny.forEach((k, i) => { offsets[k] = centers[k] - ys[i]; });
|
||||||
|
return offsets;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 泡泡玛特期间费用结构:观察销售投入、管理成本与费用率的规模效应。 */
|
/** 泡泡玛特期间费用结构:观察销售投入、管理成本与费用率的规模效应。 */
|
||||||
export function expenseStructureChart(rows, { width, height, view = 'all' }) {
|
export function expenseStructureChart(rows, { width, height, view = 'all' }) {
|
||||||
|
const keys = ['sales_expense', 'admin_expense', 'financial_expense'];
|
||||||
|
const posStackMax = Math.max(...rows.map((r) => keys.reduce((s, k) => s + Math.max(r[k] ?? 0, 0), 0)));
|
||||||
|
const negMin = Math.min(...rows.map((r) => keys.reduce((s, k) => s + Math.min(r[k] ?? 0, 0), 0)));
|
||||||
|
// 轴范围随数据走并取整刻度,保证矮段像素判定(pxPerUnit)有确定基准
|
||||||
|
const axisMax = niceAxisMax(posStackMax);
|
||||||
|
const axisMin = Math.min(0, Math.floor(negMin / 10) * 10);
|
||||||
|
const pxPerUnit = (height - LAYOUT.gridTop - 90) / (axisMax - axisMin);
|
||||||
|
const rowOffsets = expenseFanOffsets(rows, keys, pxPerUnit);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
title: {
|
title: {
|
||||||
@@ -35,6 +72,9 @@ export function expenseStructureChart(rows, { width, height, view = 'all' }) {
|
|||||||
nameLocation: 'middle',
|
nameLocation: 'middle',
|
||||||
nameGap: 52,
|
nameGap: 52,
|
||||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: axisMin,
|
||||||
|
max: axisMax,
|
||||||
|
interval: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...axisCommon,
|
...axisCommon,
|
||||||
@@ -52,9 +92,34 @@ export function expenseStructureChart(rows, { width, height, view = 'all' }) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
series: [
|
series: [
|
||||||
{ name: '销售费用', type: 'bar', stack: 'expenses', data: col(rows, 'sales_expense'), itemStyle: { color: PALETTE.orange } },
|
...keys.map((key, ci) => ({
|
||||||
{ name: '管理费用', type: 'bar', stack: 'expenses', data: col(rows, 'admin_expense'), itemStyle: { color: PALETTE.purple } },
|
name: { sales_expense: '销售费用', admin_expense: '管理费用', financial_expense: '财务费用(净额)' }[key],
|
||||||
{ name: '财务费用(净额)', type: 'bar', stack: 'expenses', data: col(rows, 'financial_expense'), itemStyle: { color: PALETTE.blue } },
|
type: 'bar',
|
||||||
|
stack: 'expenses',
|
||||||
|
// 每段都标数值:矮段(含负值段)外置到柱体右侧,见 expenseFanOffsets
|
||||||
|
data: rows.map((row, ri) => stackedSharePoint(row[key], {
|
||||||
|
pxPerUnit,
|
||||||
|
labelColor: '#fff',
|
||||||
|
dy: rowOffsets[ri][key] ?? 0,
|
||||||
|
})),
|
||||||
|
itemStyle: { color: [PALETTE.orange, PALETTE.purple, PALETTE.blue][ci] },
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'inside',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontWeight: 500,
|
||||||
|
overflow: 'none',
|
||||||
|
// 财务费用净额多在 ±0.1 亿内,一位小数会显示成 ±0.0,此时退到两位小数
|
||||||
|
formatter: (p) => {
|
||||||
|
const t = p.value.toFixed(1);
|
||||||
|
if (t === '0.0' || t === '-0.0') return Math.abs(p.value) < 0.005 ? '0.0' : p.value.toFixed(2);
|
||||||
|
return t;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
{
|
{
|
||||||
...lineSeries({
|
...lineSeries({
|
||||||
name: '四项费用率',
|
name: '四项费用率',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { col } from '../csv.js';
|
import { col } from '../csv.js';
|
||||||
import {
|
import {
|
||||||
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, legendStyle,
|
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, legendStyle,
|
||||||
financialViewDisclosure, financialViewLabel, shortStandalonePeriod,
|
financialViewDisclosure, financialViewLabel, shortStandalonePeriod, niceAxisMax,
|
||||||
lineSeries, footnote, watermarks, LAYOUT,
|
lineSeries, footnote, watermarks, LAYOUT,
|
||||||
} from '../theme.js';
|
} from '../theme.js';
|
||||||
|
|
||||||
@@ -39,6 +39,10 @@ export function ipChart(rows, { width, height, view = 'all' }) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 轴上限随数据走(THE MONSTERS 2025 已超 140 亿),写死会把爆款折线顶出绘图区
|
||||||
|
const values = rows.flatMap((row) => periods.map((p) => row[p])).filter(Number.isFinite);
|
||||||
|
const axisMax = niceAxisMax(Math.max(...values));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
title: {
|
title: {
|
||||||
@@ -74,8 +78,8 @@ export function ipChart(rows, { width, height, view = 'all' }) {
|
|||||||
nameGap: 52,
|
nameGap: 52,
|
||||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: axisMax,
|
||||||
interval: 20,
|
interval: axisMax % 4 === 0 ? axisMax / 4 : axisMax / 5,
|
||||||
},
|
},
|
||||||
series,
|
series,
|
||||||
graphic: [
|
graphic: [
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
/**
|
/**
|
||||||
* 泡泡玛特主要 IP 收入占比(2026H1)
|
* 泡泡玛特主要 IP 收入占比(2026H1)
|
||||||
* 饼图(非环图),图例放在右侧:每项上行 IP 名、下行小字显示收入和占比。
|
* 饼图(非环图),图例放在右侧:每项上行 IP 名、下行小字显示收入和占比。
|
||||||
* 扇区外直接标注 IP 名 + 数值(两行)。源:ppmt.xlsx「IP收入和占比」2026H1 列,
|
* 扇区外直接标注 IP 名 + 数值/占比(两行),IP 名与扇区同色。源:ppmt.xlsx「IP收入和占比」2026H1 列,
|
||||||
* HACIPUPU、BUNNY、PUCKY 等该期无数据的 IP 不列入。
|
* HACIPUPU、BUNNY、PUCKY 等该期无数据的 IP 不列入。
|
||||||
* 占比为衍生指标:各 IP 收入 ÷ 全部列入 IP 收入合计(源表占比列未填)。
|
* 占比为衍生指标:各 IP 收入 ÷ 全部列入 IP 收入合计(源表占比列未填)。
|
||||||
*
|
*
|
||||||
@@ -29,18 +29,28 @@ const COLORS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function ppmtIpShareChart(rows, { width, height }) {
|
export function ppmtIpShareChart(rows, { width, height }) {
|
||||||
const data = rows.map((row, i) => ({
|
const data = rows.map((row, i) => {
|
||||||
|
const color = COLORS[i % COLORS.length];
|
||||||
|
return {
|
||||||
name: row.ip,
|
name: row.ip,
|
||||||
value: row.revenue,
|
value: row.revenue,
|
||||||
itemStyle: { color: COLORS[i % COLORS.length] },
|
itemStyle: { color },
|
||||||
}));
|
// 逐扇区覆盖标签 rich:IP 名与所在区块同色,其余样式沿用系列级配置
|
||||||
|
label: {
|
||||||
|
rich: {
|
||||||
|
name: { fontFamily: FONT, fontSize: 12, fontWeight: 600, color, lineHeight: 16 },
|
||||||
|
value: { fontFamily: FONT, fontSize: 11, color: PALETTE.grey, lineHeight: 15 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
const total = data.reduce((s, d) => s + d.value, 0);
|
const total = data.reduce((s, d) => s + d.value, 0);
|
||||||
const byName = Object.fromEntries(data.map((d) => [d.name, d]));
|
const byName = Object.fromEntries(data.map((d) => [d.name, d]));
|
||||||
|
|
||||||
// 把「饼图 + 两侧外标签」当成一个整体,与图例并成一组居中摆放:
|
// 把「饼图 + 两侧外标签」当成一个整体,与图例并成一组居中摆放:
|
||||||
// 图例紧跟在右侧标签外缘之后,不会被推到画布边缘留下大片空白
|
// 图例紧跟在右侧标签外缘之后,不会被推到画布边缘留下大片空白
|
||||||
const LEGEND_WIDTH = 152; // 最宽一行「17.7 亿元 · 10.3%」约 120px + 色块
|
const LEGEND_WIDTH = 152; // 最宽一行「17.7 亿元 · 10.3%」约 120px + 色块
|
||||||
const LEGEND_GAP = 24; // 右侧标签外缘 → 图例
|
const LEGEND_GAP = 56; // 右侧标签外缘 → 图例;最长数值行会侵入该间隙,实测 24px 太挤
|
||||||
// 左右标签占位不对称:左侧文字向左延伸(text-anchor: end)要占满,
|
// 左右标签占位不对称:左侧文字向左延伸(text-anchor: end)要占满,
|
||||||
// 右侧只需容下引导线 + 首行文字起段,留太多会在图例前空出一大块
|
// 右侧只需容下引导线 + 首行文字起段,留太多会在图例前空出一大块
|
||||||
const LABEL_LEFT = 135;
|
const LABEL_LEFT = 135;
|
||||||
@@ -105,7 +115,7 @@ export function ppmtIpShareChart(rows, { width, height }) {
|
|||||||
data,
|
data,
|
||||||
label: {
|
label: {
|
||||||
show: true,
|
show: true,
|
||||||
formatter: (p) => `{name|${p.name}}\n{value|${p.value.toFixed(1)} 亿元}`,
|
formatter: (p) => `{name|${p.name}}\n{value|${p.value.toFixed(1)} 亿元 / ${p.percent.toFixed(1)}%}`,
|
||||||
rich: {
|
rich: {
|
||||||
name: { fontFamily: FONT, fontSize: 12, color: PALETTE.ink, fontWeight: 600, lineHeight: 16 },
|
name: { fontFamily: FONT, fontSize: 12, color: PALETTE.ink, fontWeight: 600, lineHeight: 16 },
|
||||||
value: { fontFamily: FONT, fontSize: 11, color: PALETTE.grey, lineHeight: 15 },
|
value: { fontFamily: FONT, fontSize: 11, color: PALETTE.grey, lineHeight: 15 },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
PALETTE, FONT, axisCommon, categoryAxis, titleStyle,
|
PALETTE, FONT, axisCommon, categoryAxis, titleStyle,
|
||||||
financialViewDisclosure, financialViewLabel, shortStandalonePeriod,
|
financialViewDisclosure, financialViewLabel, shortStandalonePeriod,
|
||||||
standardLegend, barSeries, footnote, watermarks, LAYOUT,
|
standardLegend, barSeries, fanTinyLabelOffsets, stackedSharePoint, footnote, watermarks, LAYOUT,
|
||||||
} from '../theme.js';
|
} from '../theme.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,8 +40,14 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) {
|
|||||||
const periods = Object.keys(rows[0]).filter((k) => k !== 'ip');
|
const periods = Object.keys(rows[0]).filter((k) => k !== 'ip');
|
||||||
const names = rows.map((r) => r.ip);
|
const names = rows.map((r) => r.ip);
|
||||||
const totals = periods.map((p) => rows.reduce((s, r) => s + r[p], 0));
|
const totals = periods.map((p) => rows.reduce((s, r) => s + r[p], 0));
|
||||||
// 归一化:各 IP 占当期合计的比重,逐期加总恰为 100%
|
// 归一化:各 IP 占当期合计的比重,逐期加总恰为 100%。
|
||||||
const toPct = (r) => periods.map((p, i) => (r[p] / totals[i]) * 100);
|
// 转成「期间 × 系列」的行结构,供 fanTinyLabelOffsets 逐柱计算矮段偏移。
|
||||||
|
const pctRows = periods.map((p, i) => Object.fromEntries(
|
||||||
|
rows.map((r) => [r.ip, (r[p] / totals[i]) * 100]),
|
||||||
|
));
|
||||||
|
// 绘图区像素高:画布高 − grid top 145 − grid bottom 70;轴固定 0–100%
|
||||||
|
const pxPerUnit = (height - LAYOUT.gridTop - 70) / 100;
|
||||||
|
const rowOffsets = fanTinyLabelOffsets(pctRows, names, pxPerUnit);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
@@ -83,9 +89,14 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) {
|
|||||||
interval: 20,
|
interval: 20,
|
||||||
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
||||||
},
|
},
|
||||||
series: rows.map((r) => barSeries({
|
// 每个非零占比都要展示:塞得进段内的段内白字,矮段外置到柱体右侧(深字白描边)
|
||||||
|
series: rows.map((r, ci) => barSeries({
|
||||||
name: r.ip,
|
name: r.ip,
|
||||||
data: toPct(r),
|
data: pctRows.map((pr, ri) => stackedSharePoint(pr[r.ip], {
|
||||||
|
pxPerUnit,
|
||||||
|
labelColor: '#fff',
|
||||||
|
dy: rowOffsets[ri][ci] ?? 0,
|
||||||
|
})),
|
||||||
color: COLORS[r.ip] ?? PALETTE.grey,
|
color: COLORS[r.ip] ?? PALETTE.grey,
|
||||||
barWidth: 40,
|
barWidth: 40,
|
||||||
stack: 'share',
|
stack: 'share',
|
||||||
@@ -96,8 +107,8 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) {
|
|||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: FONT,
|
fontFamily: FONT,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
// 低于 4% 的段塞不进字,留空靠 tooltip 读数
|
overflow: 'none',
|
||||||
formatter: (p) => (p.value >= 4 ? `${p.value.toFixed(1)}%` : ''),
|
formatter: (p) => `${p.value.toFixed(1)}%`,
|
||||||
},
|
},
|
||||||
emphasis: { focus: 'series' },
|
emphasis: { focus: 'series' },
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import {
|
|
||||||
PALETTE, FONT, titleStyle, footnote, watermarks, legendStyle,
|
|
||||||
LAYOUT, } from '../theme.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 泡泡玛特区域渠道分布图(2026H1)
|
|
||||||
* 使用交错正负轴展示不同区域和渠道的收入
|
|
||||||
*/
|
|
||||||
export function regionChannelChart(rows, { width, height }) {
|
|
||||||
// 数据结构重组:按区域分组
|
|
||||||
const regions = ['中国', '海外', '亚太', '美洲', '欧洲及其他'];
|
|
||||||
const channels = ['线下渠道', '线上渠道', '批发及其他'];
|
|
||||||
|
|
||||||
// 构建数据映射
|
|
||||||
const dataMap = {};
|
|
||||||
rows.forEach(row => {
|
|
||||||
const key = `${row.region}_${row.channel}`;
|
|
||||||
dataMap[key] = row.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 为每个渠道构建系列数据
|
|
||||||
const seriesData = channels.map(channel => {
|
|
||||||
return regions.map(region => {
|
|
||||||
const key = `${region}_${channel}`;
|
|
||||||
const value = dataMap[key] || 0;
|
|
||||||
// 线上渠道显示为负值(左侧),其他为正值(右侧)
|
|
||||||
return channel === '线上渠道' ? -value : value;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 配色
|
|
||||||
const channelColors = {
|
|
||||||
'线下渠道': PALETTE.navy,
|
|
||||||
'线上渠道': PALETTE.coral,
|
|
||||||
'批发及其他': PALETTE.sage,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
title: {
|
|
||||||
...titleStyle,
|
|
||||||
text: '泡泡玛特区域渠道分布(2026H1)',
|
|
||||||
subtext: '左侧:线上渠道 | 右侧:线下渠道、批发及其他,单位:亿元',
|
|
||||||
top: 18,
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
...legendStyle,
|
|
||||||
data: channels,
|
|
||||||
top: LAYOUT.legendTop,
|
|
||||||
itemGap: 32,
|
|
||||||
textStyle: { fontFamily: FONT, fontSize: 13 },
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: { type: 'shadow' },
|
|
||||||
textStyle: { fontFamily: FONT },
|
|
||||||
formatter: (params) => {
|
|
||||||
let result = `<b>${params[0].axisValue}</b><br/>`;
|
|
||||||
params.forEach(p => {
|
|
||||||
const value = Math.abs(p.value);
|
|
||||||
result += `${p.marker}${p.seriesName}: ${value.toFixed(2)} 亿元<br/>`;
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
left: 100,
|
|
||||||
right: 100,
|
|
||||||
top: 145,
|
|
||||||
bottom: 90,
|
|
||||||
containLabel: false,
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'value',
|
|
||||||
position: 'top',
|
|
||||||
splitLine: {
|
|
||||||
show: true,
|
|
||||||
lineStyle: { color: '#e8e8e8', width: 1 }
|
|
||||||
},
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisTick: { show: false },
|
|
||||||
axisLabel: {
|
|
||||||
formatter: (value) => Math.abs(value),
|
|
||||||
fontFamily: FONT,
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#8c8c8c',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: regions,
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisTick: { show: false },
|
|
||||||
axisLabel: {
|
|
||||||
fontFamily: FONT,
|
|
||||||
fontSize: 13,
|
|
||||||
color: '#333',
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
inverse: false,
|
|
||||||
},
|
|
||||||
series: channels.map((channel, idx) => ({
|
|
||||||
name: channel,
|
|
||||||
type: 'bar',
|
|
||||||
stack: channel === '线上渠道' ? 'left' : 'right',
|
|
||||||
data: seriesData[idx],
|
|
||||||
itemStyle: { color: channelColors[channel] },
|
|
||||||
label: {
|
|
||||||
show: true,
|
|
||||||
position: channel === '线上渠道' ? 'left' : 'right',
|
|
||||||
formatter: (params) => {
|
|
||||||
const value = Math.abs(params.value);
|
|
||||||
return value > 0 ? value.toFixed(1) : '';
|
|
||||||
},
|
|
||||||
fontFamily: FONT,
|
|
||||||
fontSize: 11,
|
|
||||||
color: '#333',
|
|
||||||
},
|
|
||||||
barWidth: 22,
|
|
||||||
})),
|
|
||||||
graphic: [
|
|
||||||
...watermarks(width, height),
|
|
||||||
footnote('数据来源:泡泡玛特财报 | @Joey知行记'),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -4,11 +4,13 @@ import {
|
|||||||
financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT,
|
financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT,
|
||||||
} from '../theme.js';
|
} from '../theme.js';
|
||||||
|
|
||||||
|
// 同比虚线用柱色的同色系深色变体:压在柱体和彼此之间都能区分,色相仍与柱对应
|
||||||
const REGIONS = [
|
const REGIONS = [
|
||||||
{ key: 'china', name: '中国(含港澳台)', color: '#3F7FBE' },
|
{ key: 'china', name: '中国(含港澳台)', color: '#3F7FBE', lineColor: PALETTE.navy },
|
||||||
{ key: 'apac', name: '亚太', color: '#36A28F' },
|
// 亚太青、美洲金、深棕在色板无对应深色,按柱色加深或取邻近深色
|
||||||
{ key: 'americas', name: '美洲', color: '#E4A349' },
|
{ key: 'apac', name: '亚太', color: '#36A28F', lineColor: '#1F6E5F' },
|
||||||
{ key: 'europe_other', name: '欧洲及其他', color: PALETTE.brown },
|
{ key: 'americas', name: '美洲', color: '#E4A349', lineColor: PALETTE.orange },
|
||||||
|
{ key: 'europe_other', name: '欧洲及其他', color: PALETTE.brown, lineColor: '#4A3117' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function yoyByPeriod(rows, key) {
|
function yoyByPeriod(rows, key) {
|
||||||
@@ -37,7 +39,7 @@ export function retailStoreRevenueChart(rows, { width, height, view = 'all' }) {
|
|||||||
title: {
|
title: {
|
||||||
...titleStyle,
|
...titleStyle,
|
||||||
text: `泡泡玛特各地区零售店单店收入(${financialViewLabel(view)})`,
|
text: `泡泡玛特各地区零售店单店收入(${financialViewLabel(view)})`,
|
||||||
subtext: '柱状为单店收入;同色虚线为单店收入同比;H2为下半年单期',
|
subtext: '柱状为单店收入;深色虚线为对应柱的单店收入同比;H2为下半年单期',
|
||||||
},
|
},
|
||||||
legend: standardLegend(legendItems, { itemGap: 18 }),
|
legend: standardLegend(legendItems, { itemGap: 18 }),
|
||||||
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
|
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
|
||||||
@@ -93,12 +95,12 @@ export function retailStoreRevenueChart(rows, { width, height, view = 'all' }) {
|
|||||||
...lineSeries({
|
...lineSeries({
|
||||||
name: `${region.name}同比`,
|
name: `${region.name}同比`,
|
||||||
data: region.data,
|
data: region.data,
|
||||||
color: region.color,
|
color: region.lineColor,
|
||||||
width: 2,
|
width: 2,
|
||||||
formatter: (point) => `${point.value.toFixed(0)}%`,
|
formatter: (point) => `${point.value.toFixed(0)}%`,
|
||||||
}),
|
}),
|
||||||
yAxisIndex: 1,
|
yAxisIndex: 1,
|
||||||
lineStyle: { color: region.color, width: 2, type: 'dashed' },
|
lineStyle: { color: region.lineColor, width: 2, type: 'dashed' },
|
||||||
z: 10,
|
z: 10,
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import {
|
|||||||
financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT,
|
financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT,
|
||||||
} from '../theme.js';
|
} from '../theme.js';
|
||||||
|
|
||||||
|
// 同比虚线用柱色的同色系深色变体:压在柱体和彼此之间都能区分,色相仍与柱对应
|
||||||
const REGIONS = [
|
const REGIONS = [
|
||||||
{ key: 'china', name: '中国(含港澳台)', color: '#71AD81' },
|
{ key: 'china', name: '中国(含港澳台)', color: '#71AD81', lineColor: PALETTE.green },
|
||||||
{ key: 'overseas', name: '海外', color: PALETTE.brown },
|
// 色板没有深棕,按 brown 加深
|
||||||
|
{ key: 'overseas', name: '海外', color: PALETTE.brown, lineColor: '#4A3117' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function yoyByPeriod(rows, key) {
|
function yoyByPeriod(rows, key) {
|
||||||
@@ -35,7 +37,7 @@ export function robotStoreRevenueChart(rows, { width, height, view = 'all' }) {
|
|||||||
title: {
|
title: {
|
||||||
...titleStyle,
|
...titleStyle,
|
||||||
text: `泡泡玛特机器人商店单店收入(${financialViewLabel(view)})`,
|
text: `泡泡玛特机器人商店单店收入(${financialViewLabel(view)})`,
|
||||||
subtext: '柱状为单店收入;同色虚线为单店收入同比;H2为下半年单期',
|
subtext: '柱状为单店收入;深色虚线为对应柱的单店收入同比;H2为下半年单期',
|
||||||
},
|
},
|
||||||
legend: standardLegend(legendItems, { itemGap: 18 }),
|
legend: standardLegend(legendItems, { itemGap: 18 }),
|
||||||
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
|
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
|
||||||
@@ -91,12 +93,12 @@ export function robotStoreRevenueChart(rows, { width, height, view = 'all' }) {
|
|||||||
...lineSeries({
|
...lineSeries({
|
||||||
name: `${region.name}同比`,
|
name: `${region.name}同比`,
|
||||||
data: region.data,
|
data: region.data,
|
||||||
color: region.color,
|
color: region.lineColor,
|
||||||
width: 2,
|
width: 2,
|
||||||
formatter: (point) => `${point.value.toFixed(0)}%`,
|
formatter: (point) => `${point.value.toFixed(0)}%`,
|
||||||
}),
|
}),
|
||||||
yAxisIndex: 1,
|
yAxisIndex: 1,
|
||||||
lineStyle: { color: region.color, width: 2, type: 'dashed' },
|
lineStyle: { color: region.lineColor, width: 2, type: 'dashed' },
|
||||||
z: 10,
|
z: 10,
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -161,6 +161,15 @@ export const LAYOUT = {
|
|||||||
gridTop: 145, // 绘图区顶部边距(图例到绘图区约 60px)
|
gridTop: 145, // 绘图区顶部边距(图例到绘图区约 60px)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 数值轴整齐上限候选:轴 max 从中取第一个能容纳数据的刻度 */
|
||||||
|
export const NICE_AXIS_MAX = [20, 25, 50, 60, 80, 100, 120, 150, 200, 250, 300, 400, 500, 600, 800, 1000];
|
||||||
|
|
||||||
|
/** 按数据最大值取整齐轴上限;超出候选表尾时按 100 的整数倍向上取整并留 20% 余量 */
|
||||||
|
export function niceAxisMax(dataMax) {
|
||||||
|
return NICE_AXIS_MAX.find((max) => max >= dataMax * 1.02)
|
||||||
|
?? Math.ceil((dataMax * 1.2) / 100) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
// 统一图例图标宽度,同时保留 ECharts 按系列类型生成的形状。
|
// 统一图例图标宽度,同时保留 ECharts 按系列类型生成的形状。
|
||||||
export const LEGEND_ITEM_WIDTH = 20;
|
export const LEGEND_ITEM_WIDTH = 20;
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -18,7 +18,6 @@
|
|||||||
"ppmt-ip-share-history": { "key": "ppmt-ip-share-history", "title": "各 IP 半年收入占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_ip_share_history.csv", "annual": "data/ppmt/ppmt_ip_share_history_annual.csv", "halfyear": "data/ppmt/ppmt_ip_share_history_halfyear.csv" }, "height": 700, "filename": "泡泡玛特各IP半年收入占比" },
|
"ppmt-ip-share-history": { "key": "ppmt-ip-share-history", "title": "各 IP 半年收入占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_ip_share_history.csv", "annual": "data/ppmt/ppmt_ip_share_history_annual.csv", "halfyear": "data/ppmt/ppmt_ip_share_history_halfyear.csv" }, "height": 700, "filename": "泡泡玛特各IP半年收入占比" },
|
||||||
"ppmt-ip-ex-monsters": { "key": "ppmt-ip-ex-monsters", "title": "除 THE MONSTERS 外的 IP 收入及同比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_ip_ex_monsters.csv", "annual": "data/ppmt/ppmt_ip_ex_monsters_annual.csv", "halfyear": "data/ppmt/ppmt_ip_ex_monsters_halfyear.csv" }, "height": 700, "filename": "泡泡玛特除MONSTERS外IP收入及同比" },
|
"ppmt-ip-ex-monsters": { "key": "ppmt-ip-ex-monsters", "title": "除 THE MONSTERS 外的 IP 收入及同比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_ip_ex_monsters.csv", "annual": "data/ppmt/ppmt_ip_ex_monsters_annual.csv", "halfyear": "data/ppmt/ppmt_ip_ex_monsters_halfyear.csv" }, "height": 700, "filename": "泡泡玛特除MONSTERS外IP收入及同比" },
|
||||||
"ppmt-category": { "key": "ppmt-category", "title": "品类收入结构", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_category.csv", "annual": "data/ppmt/ppmt_category_annual.csv", "halfyear": "data/ppmt/ppmt_category_halfyear.csv" }, "height": 680, "filename": "泡泡玛特品类结构" },
|
"ppmt-category": { "key": "ppmt-category", "title": "品类收入结构", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_category.csv", "annual": "data/ppmt/ppmt_category_annual.csv", "halfyear": "data/ppmt/ppmt_category_halfyear.csv" }, "height": 680, "filename": "泡泡玛特品类结构" },
|
||||||
"ppmt-region-channel": { "key": "ppmt-region-channel", "title": "区域渠道分布", "csv": "data/ppmt/ppmt_region_channel_2026h1.csv", "height": 680, "filename": "泡泡玛特区域渠道分布2026H1" },
|
|
||||||
"ppmt-region-yoy": { "key": "ppmt-region-yoy", "title": "区域渠道同比增速", "csv": "data/ppmt/ppmt_region_channel_yoy.csv", "height": 620, "filename": "泡泡玛特区域渠道同比增速" },
|
"ppmt-region-yoy": { "key": "ppmt-region-yoy", "title": "区域渠道同比增速", "csv": "data/ppmt/ppmt_region_channel_yoy.csv", "height": 620, "filename": "泡泡玛特区域渠道同比增速" },
|
||||||
"ppmt-region-yoy-timeseries": { "key": "ppmt-region-yoy-timeseries", "title": "区域渠道同比增速趋势", "csv": "data/ppmt/ppmt_region_channel_yoy_timeseries.csv", "height": 760, "filename": "泡泡玛特区域渠道同比增速趋势" },
|
"ppmt-region-yoy-timeseries": { "key": "ppmt-region-yoy-timeseries", "title": "区域渠道同比增速趋势", "csv": "data/ppmt/ppmt_region_channel_yoy_timeseries.csv", "height": 760, "filename": "泡泡玛特区域渠道同比增速趋势" },
|
||||||
"ppmt-cash-conversion": { "key": "ppmt-cash-conversion", "title": "经营现金流与净利润", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_cash_conversion.csv", "annual": "data/ppmt/ppmt_cash_conversion_annual.csv", "halfyear": "data/ppmt/ppmt_cash_conversion_halfyear.csv" }, "height": 700, "filename": "泡泡玛特经营现金流与净利润" },
|
"ppmt-cash-conversion": { "key": "ppmt-cash-conversion", "title": "经营现金流与净利润", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_cash_conversion.csv", "annual": "data/ppmt/ppmt_cash_conversion_annual.csv", "halfyear": "data/ppmt/ppmt_cash_conversion_halfyear.csv" }, "height": 700, "filename": "泡泡玛特经营现金流与净利润" },
|
||||||
@@ -44,12 +43,12 @@
|
|||||||
"ppmt-region-share": { "key": "ppmt-region-share", "title": "分地区营收占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_region_share.csv", "annual": "data/ppmt/ppmt_region_share_annual.csv", "halfyear": "data/ppmt/ppmt_region_share_halfyear.csv" }, "height": 680, "filename": "泡泡玛特分地区营收占比" }
|
"ppmt-region-share": { "key": "ppmt-region-share", "title": "分地区营收占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_region_share.csv", "annual": "data/ppmt/ppmt_region_share_annual.csv", "halfyear": "data/ppmt/ppmt_region_share_halfyear.csv" }, "height": 680, "filename": "泡泡玛特分地区营收占比" }
|
||||||
},
|
},
|
||||||
"tabs": [
|
"tabs": [
|
||||||
{ "id": "overview", "name": "概览", "chartRefs": ["ppmt-growth", "ppmt-profit", "ppmt-ip-share", "ppmt-region-channel"] },
|
{ "id": "overview", "name": "概览", "chartRefs": ["ppmt-growth", "ppmt-profit", "ppmt-ip-share"] },
|
||||||
{ "id": "profitability", "name": "盈利能力", "chartRefs": ["ppmt-growth", "ppmt-core-profit", "ppmt-core-margin", "ppmt-bridge", "ppmt-sankey", "ppmt-cash-conversion", "ppmt-cashflow-composition"] },
|
{ "id": "profitability", "name": "盈利能力", "chartRefs": ["ppmt-growth", "ppmt-core-profit", "ppmt-core-margin", "ppmt-bridge", "ppmt-sankey", "ppmt-cash-conversion", "ppmt-cashflow-composition"] },
|
||||||
{ "id": "expense", "name": "费用率", "chartRefs": ["ppmt-margins", "ppmt-expense-structure"] },
|
{ "id": "expense", "name": "费用率", "chartRefs": ["ppmt-margins", "ppmt-expense-structure"] },
|
||||||
{ "id": "balance-sheet", "name": "资产负债", "chartRefs": ["ppmt-assets", "ppmt-inventory", "ppmt-liquidity-working-capital", "ppmt-working-capital-days"] },
|
{ "id": "balance-sheet", "name": "资产负债", "chartRefs": ["ppmt-assets", "ppmt-inventory", "ppmt-liquidity-working-capital", "ppmt-working-capital-days"] },
|
||||||
{ "id": "ip-category", "name": "IP 与品类", "chartRefs": ["ppmt-ip", "ppmt-ip-share", "ppmt-ip-share-history", "ppmt-ip-ex-monsters", "ppmt-category"] },
|
{ "id": "ip-category", "name": "IP 与品类", "chartRefs": ["ppmt-ip", "ppmt-ip-share", "ppmt-ip-share-history", "ppmt-ip-ex-monsters", "ppmt-category"] },
|
||||||
{ "id": "region-channel", "name": "区域渠道", "chartRefs": ["ppmt-region-channel", "ppmt-region-yoy", "ppmt-region-yoy-timeseries", "ppmt-channel-revenue", "ppmt-channel-share", "ppmt-channel-share-china", "ppmt-channel-amount-china", "ppmt-channel-share-apac", "ppmt-channel-amount-apac", "ppmt-channel-share-americas", "ppmt-channel-amount-americas", "ppmt-channel-share-europe", "ppmt-channel-amount-europe", "ppmt-stores-retail", "ppmt-stores-retail-share", "ppmt-stores-robot", "ppmt-region-share"] },
|
{ "id": "region-channel", "name": "区域渠道", "chartRefs": ["ppmt-region-yoy", "ppmt-region-yoy-timeseries", "ppmt-channel-revenue", "ppmt-channel-share", "ppmt-channel-share-china", "ppmt-channel-amount-china", "ppmt-channel-share-apac", "ppmt-channel-amount-apac", "ppmt-channel-share-americas", "ppmt-channel-amount-americas", "ppmt-channel-share-europe", "ppmt-channel-amount-europe", "ppmt-stores-retail", "ppmt-stores-retail-share", "ppmt-stores-robot", "ppmt-region-share"] },
|
||||||
{ "id": "store-revenue", "name": "单店收入", "chartRefs": ["ppmt-retail-store-revenue", "ppmt-robot-store-revenue"] }
|
{ "id": "store-revenue", "name": "单店收入", "chartRefs": ["ppmt-retail-store-revenue", "ppmt-robot-store-revenue"] }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user