155 lines
6.3 KiB
JavaScript
155 lines
6.3 KiB
JavaScript
import { col } from '../csv.js';
|
||
import {
|
||
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, labelStyle,
|
||
barSeries, lineSeries, standardLegend, footnote, watermarks, LAYOUT,
|
||
} from '../theme.js';
|
||
|
||
/**
|
||
* 泡泡玛特除 THE MONSTERS 外的 IP 收入及同比(2021H1-2026H1)
|
||
* 柱 = 各期 IP 收入合计 − THE MONSTERS,折线 = 同比增速。
|
||
* 看的是「剔掉唯一爆款后,其余 IP 盘子还在不在长」。
|
||
*
|
||
* 源:ppmt.xlsx「IP收入和占比」收入区的「计算合计」行减去 THE MONSTERS 行。
|
||
*
|
||
* ⚠ 这张表的 H2 是【单半年】,不是全年累计(已核对 2025H2 的 232.44
|
||
* = 利润表 371.2 − 138.76)。两个后果:
|
||
* 1. 轴标签只去世纪前缀(25H2),不能用 `periodAxis`(它会渲染成「25全年」)
|
||
* 2. 同比是「本期 vs 去年同半年」,即隔两列相除;前两期无基数,留空
|
||
*
|
||
* CSV 收入存两位小数:同比对基数的舍入敏感,一位小数在 16~20 亿的基数上
|
||
* 会让同比偏掉约 0.5pct。实测两位小数复算同比与全精度差 ≤ 0.02pct。
|
||
*
|
||
* 配色:墨翡翠柱 + 鲜亮紫线,两色 ΔE 隔着老远。
|
||
* 翡翠选得比紧邻堆叠图里 DIMOO 那支(#3E9E6E)更深,ΔE 22.7,不会被读成同一个色;
|
||
* 同时深到白字对比度 6.44:1,够柱内白色标签用。
|
||
*
|
||
* 标签避让(不加底色,白色药丸底太丑):
|
||
* 1. 柱子的数值放柱内白字(insideTop)——腾开柱顶上方,不和同比标签抢位置
|
||
* 2. 同比标签逐点判断:算出标签底边和柱顶的相对位置,只有整体压在柱面上的
|
||
* 那个点(26H1,线在 45%、柱顶在 80%)改用浅紫字,其余保持线色
|
||
*
|
||
* 浅紫而非白色:柱子自己的数值已经是白字,同比再用白字两者会混。
|
||
*/
|
||
const BAR_COLOR = '#176B52'; // 墨翡翠
|
||
const LINE_COLOR = '#7A3FE0'; // 鲜亮紫
|
||
const LINE_ON_BAR = '#DCC9FF'; // 压在翡翠柱面上时用的浅紫(对比度约 6:1)
|
||
|
||
const Y_MAX = 160; // 左轴上限
|
||
const YOY_MIN = -50; // 右轴下限
|
||
const YOY_MAX = 150; // 右轴上限
|
||
|
||
export function ppmtIpExMonstersChart(rows, { width, height }) {
|
||
const periods = col(rows, 'period');
|
||
const revenue = col(rows, 'revenue');
|
||
// 前两期无同比基数,CSV 里留空;parseCSV 会给出 '',统一转成 null
|
||
const yoy = col(rows, 'yoy').map((v) => (v === '' ? null : v));
|
||
|
||
// 逐点判断同比标签会不会压在柱面上。`label.position` 不接受函数(见 CLAUDE.md),
|
||
// 所以只能把 data 写成对象、逐点覆盖 color。
|
||
const plotH = height - LAYOUT.gridTop - 90; // 与下面 grid 的 top/bottom 一致
|
||
const labelGap = (8 + 13) / plotH; // labelStyle 的 distance + 字高,换成占比
|
||
const yoyData = yoy.map((v, i) => {
|
||
if (v === null) return null;
|
||
const barFrac = revenue[i] / Y_MAX;
|
||
const lineFrac = (v - YOY_MIN) / (YOY_MAX - YOY_MIN);
|
||
// 标签整体(底边到顶边)都落在柱子高度以内 → 压在柱面上
|
||
const onBar = lineFrac + labelGap < barFrac;
|
||
return onBar ? { value: v, label: { color: LINE_ON_BAR } } : v;
|
||
});
|
||
|
||
return {
|
||
backgroundColor: '#fff',
|
||
title: {
|
||
...titleStyle,
|
||
text: '泡泡玛特除 THE MONSTERS 外的IP收入及同比(21H1-26H1)',
|
||
subtext: 'IP收入合计 − THE MONSTERS,单位:亿元;H2 为下半年单期,非全年累计',
|
||
},
|
||
legend: standardLegend(['除MONSTERS外收入', '同比增速'], { itemGap: 32 }),
|
||
grid: { left: 78, right: 128, top: LAYOUT.gridTop, bottom: 90 },
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
axisPointer: { type: 'cross' },
|
||
textStyle: { fontFamily: FONT },
|
||
formatter: (ps) => {
|
||
let out = `<b>${ps[0].axisValue}</b><br/>`;
|
||
ps.forEach((p) => {
|
||
if (p.seriesName === '除MONSTERS外收入') {
|
||
out += `${p.marker}${p.seriesName}: ${p.value.toFixed(1)} 亿元<br/>`;
|
||
} else if (p.value !== null && p.value !== '') {
|
||
out += `${p.marker}${p.seriesName}: ${p.value > 0 ? '+' : ''}${p.value.toFixed(1)}%<br/>`;
|
||
}
|
||
});
|
||
return out;
|
||
},
|
||
},
|
||
xAxis: {
|
||
...categoryAxis,
|
||
data: periods,
|
||
// 只去世纪前缀:这张表的 H2 是单半年,不能简写成「全年」
|
||
axisLabel: { ...categoryAxis.axisLabel, formatter: (v) => v.replace(/^20/, '') },
|
||
},
|
||
yAxis: [
|
||
{
|
||
...axisCommon,
|
||
type: 'value',
|
||
name: '收入(亿元)',
|
||
nameLocation: 'middle',
|
||
nameGap: 50,
|
||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||
min: 0,
|
||
// 定死 160:留出顶部余量,最高柱(139.0)不会顶到绘图区上沿
|
||
max: Y_MAX,
|
||
interval: 20,
|
||
},
|
||
{
|
||
type: 'value',
|
||
name: '同比增速(%)',
|
||
nameLocation: 'middle',
|
||
nameGap: 60,
|
||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||
min: YOY_MIN,
|
||
max: YOY_MAX,
|
||
interval: 50,
|
||
axisLine: { show: false },
|
||
axisTick: { show: false },
|
||
splitLine: { show: false },
|
||
axisLabel: { color: PALETTE.grey, fontSize: 12, fontFamily: FONT, formatter: '{value}%' },
|
||
},
|
||
],
|
||
series: [
|
||
barSeries({
|
||
name: '除MONSTERS外收入',
|
||
data: revenue,
|
||
color: BAR_COLOR,
|
||
barWidth: 34,
|
||
yAxisIndex: 0,
|
||
// 数值放柱内白字:腾开柱顶上方,避免和同比线的标签抢位置
|
||
label: {
|
||
show: true,
|
||
position: 'insideTop',
|
||
color: '#fff',
|
||
fontFamily: FONT,
|
||
fontSize: 11,
|
||
fontWeight: 500,
|
||
formatter: (p) => p.value.toFixed(1),
|
||
overflow: 'none', // 矮柱也要显示,见 CLAUDE.md
|
||
},
|
||
}),
|
||
{
|
||
// lineSeries() 没有 ...rest,yAxisIndex 只能展开后再补
|
||
...lineSeries({ name: '同比增速', data: yoyData, color: LINE_COLOR }),
|
||
yAxisIndex: 1,
|
||
label: {
|
||
...labelStyle(LINE_COLOR, 'top'),
|
||
formatter: (p) => (p.value === null || p.value === '' ? ''
|
||
: `${p.value > 0 ? '+' : ''}${p.value.toFixed(1)}%`),
|
||
},
|
||
},
|
||
],
|
||
graphic: [
|
||
...watermarks(width, height),
|
||
footnote('除 THE MONSTERS 外其余 IP 合计;同比对去年同半年 | @思考的Joey'),
|
||
],
|
||
};
|
||
}
|