Files
smart-fin-chart/js/charts/ppmt_category.js
T
2026-09-10 17:44:09 +08:00

182 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
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'),
},
// 图例柱与同比线交错排列,同品类的两项相邻
legend: {
...legendStyle,
top: LAYOUT.legendTop,
data: CATEGORIES.flatMap((c) => [c.name, `${c.name}同比`]),
},
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/>`;
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/>`;
});
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: 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: [
...CATEGORIES.map((c, ci) => ({
name: c.name,
type: 'bar',
stack: 'category',
// 每段都标数值:矮段外置到柱体右侧,机制同渠道金额图
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,
position: 'inside',
color: '#fff',
fontSize: 12,
fontFamily: FONT,
overflow: 'none',
formatter: (p) => `${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('数据来源:泡泡玛特财报;同比=当期÷上年同期−1\n2024年各期无上年基数,不计算同比 | @Joey知行记'),
],
};
}