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 = `${ps[0].axisValue}
`; ps.forEach((p) => { if (p.seriesName === '除MONSTERS外收入') { out += `${p.marker}${p.seriesName}: ${p.value.toFixed(1)} 亿元
`; } else if (p.value !== null && p.value !== '') { out += `${p.marker}${p.seriesName}: ${p.value > 0 ? '+' : ''}${p.value.toFixed(1)}%
`; } }); 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'), ], }; }