feat: 优化泡泡玛特的图表
This commit is contained in:
+130
-77
@@ -1,66 +1,150 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
|
||||
financialViewDisclosure, financialViewLabel,
|
||||
footnote, watermarks, LAYOUT,
|
||||
financialViewDisclosure, financialViewLabel, niceAxisMax,
|
||||
fanTinyLabelOffsets, stackedSharePoint,
|
||||
lineSeries, footnote, watermarks, LAYOUT,
|
||||
} 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' }) {
|
||||
// 过滤掉表头行("品类"那一行)
|
||||
const dataRows = rows.filter(r => r.period && r.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 {
|
||||
backgroundColor: '#fff',
|
||||
title: {
|
||||
...titleStyle,
|
||||
text: `泡泡玛特品类收入结构(${financialViewLabel(view)})`,
|
||||
subtext: ['单位:亿元;H2为下半年单期', financialViewDisclosure(view)].filter(Boolean).join('\n'),
|
||||
subtext: ['柱为收入(亿元,左轴);折线为各品类收入同比(右轴);H2为下半年单期',
|
||||
financialViewDisclosure(view)].filter(Boolean).join('\n'),
|
||||
},
|
||||
// 图例柱与同比线交错排列,同品类的两项相邻
|
||||
legend: {
|
||||
...legendStyle,
|
||||
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: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
textStyle: { fontFamily: FONT },
|
||||
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 total = 0;
|
||||
params.forEach(p => {
|
||||
total += p.value;
|
||||
result += `${p.marker}${p.seriesName}: ${p.value.toFixed(2)} 亿元<br/>`;
|
||||
bars.forEach((p) => { result += `${p.marker}${p.seriesName}: ${p.value.toFixed(2)} 亿元<br/>`; });
|
||||
result += `<b>合计: ${total.toFixed(2)} 亿元</b><br/>`;
|
||||
lines.forEach((p) => {
|
||||
result += `${p.marker}${p.seriesName}: ${p.value > 0 ? '+' : ''}${p.value.toFixed(1)}%<br/>`;
|
||||
});
|
||||
result += `<b>合计: ${total.toFixed(2)} 亿元</b>`;
|
||||
return result;
|
||||
},
|
||||
},
|
||||
xAxis: { ...standalonePeriodAxis, data: periods },
|
||||
yAxis: {
|
||||
...axisCommon,
|
||||
type: 'value',
|
||||
name: '收入(亿元)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 52,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: 0,
|
||||
max: 250,
|
||||
interval: 50,
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
...axisCommon,
|
||||
type: 'value',
|
||||
name: '收入(亿元)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 52,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: 0,
|
||||
max: axisMax,
|
||||
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: [
|
||||
{
|
||||
name: '毛绒',
|
||||
...CATEGORIES.map((c, ci) => ({
|
||||
name: c.name,
|
||||
type: 'bar',
|
||||
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' },
|
||||
label: {
|
||||
show: true,
|
||||
@@ -68,61 +152,30 @@ export function categoryChart(rows, { width, height, view = 'all' }) {
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
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: [
|
||||
...watermarks(width, height),
|
||||
footnote('数据来源:泡泡玛特财报 | @Joey知行记'),
|
||||
footnote('数据来源:泡泡玛特财报;同比=当期÷上年同期−1\n2024年各期无上年基数,不计算同比 | @Joey知行记'),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user