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 = `${params[0].axisValue}
`; bars.forEach((p) => { result += `${p.marker}${p.seriesName}: ${p.value.toFixed(2)} 亿元
`; }); result += `合计: ${total.toFixed(2)} 亿元
`; lines.forEach((p) => { result += `${p.marker}${p.seriesName}: ${p.value > 0 ? '+' : ''}${p.value.toFixed(1)}%
`; }); 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知行记'), ], }; }