Files
smart-fin-chart/js/charts/ppmt_ip_ex_monsters.js
T
2026-08-23 21:23:41 +08:00

155 lines
6.3 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, 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() 没有 ...restyAxisIndex 只能展开后再补
...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'),
],
};
}