import { col } from '../csv.js'; import { CHANNEL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend, financialViewLabel, barSeries, stackedSharePoint, fanTinyLabelOffsets, footnote, watermarks, LAYOUT, } from '../theme.js'; /** * 四地区共用同一套渠道语义色:零售店/机器人商店/批发及其他沿用渠道色板; * 线上细分逐平台一色,抖音与 TikTok 同属一条生态线共用青色,其他线上为中性灰。 * 各图 5~7 个系列,图内两两 ΔE 已验算 > 25(最紧:抖音青 vs 中性灰 25.1)。 */ const SERIES = { retail: { name: '零售店', color: CHANNEL_COLORS.retail, labelColor: '#fff' }, robot_store: { name: '机器人商店', color: CHANNEL_COLORS.robotStore, labelColor: '#fff' }, wholesale: { name: '批发及其他', color: CHANNEL_COLORS.wholesale, labelColor: PALETTE.ink }, online_other: { name: '其他线上', color: PALETTE.grey, labelColor: '#fff' }, roboshop: { name: '抽盒机', color: CHANNEL_COLORS.online, labelColor: '#fff' }, tmall: { name: '天猫旗舰店', color: '#8256B2', labelColor: '#fff' }, douyin: { name: '抖音平台', color: '#4E9EA7', labelColor: '#fff' }, own_app: { name: '自研APP及官网', color: '#C05B76', labelColor: '#fff' }, shopee: { name: 'Shopee', color: PALETTE.brown, labelColor: '#fff' }, tiktok: { name: 'TikTok', color: '#4E9EA7', labelColor: '#fff' }, amazon: { name: '亚马逊', color: PALETTE.red, labelColor: '#fff' }, }; const REGIONS = { china: { title: '中国区', source: '中国(含港澳台)', series: ['retail', 'robot_store', 'roboshop', 'tmall', 'douyin', 'online_other', 'wholesale'], footnote: '数据来源:泡泡玛特中国区(含港澳台)分渠道收入披露;H2为单半年(全年−H1)\n线上渠道按财报披露拆分为抽盒机、天猫旗舰店、抖音平台与其他线上 | @Joey知行记', }, apac: { title: '亚太区', source: '亚太区', series: ['retail', 'robot_store', 'own_app', 'shopee', 'online_other', 'wholesale'], footnote: '数据来源:泡泡玛特亚太区分渠道收入披露;H2为单半年(全年−H1)\n线上渠道按财报披露拆分为自研APP及官网、Shopee与其他线上 | @Joey知行记', }, americas: { title: '美洲区', source: '美洲区', series: ['retail', 'robot_store', 'own_app', 'tiktok', 'amazon', 'online_other', 'wholesale'], footnote: '数据来源:泡泡玛特美洲区分渠道收入披露;H2为单半年(全年−H1)\n2026H1起TikTok无收入,亚马逊为2026年新开渠道 | @Joey知行记', }, europe: { title: '欧洲及其他区', source: '欧洲及其他地区', series: ['retail', 'robot_store', 'own_app', 'online_other', 'wholesale'], footnote: '数据来源:泡泡玛特欧洲及其他地区分渠道收入披露;H2为单半年(全年−H1)\n线上渠道按财报披露拆分为自研APP及官网与其他线上 | @Joey知行记', }, }; /** 轴上限取整齐刻度,柱高比例确定后,段标签的段内/外置可按像素判断(同 ppmt_channel_revenue)。 */ const AMOUNT_NICE_MAX = [10, 20, 25, 50, 60, 80, 100, 120, 150, 200, 250, 300, 400, 500]; /** * 分地区渠道收入金额堆叠柱:与占比图同源同色,直接使用披露金额(亿元), * 不做占比×总收入还原。矮段标签外置到柱体右侧的机制同全局渠道金额图。 */ export function makeRegionChannelAmountChart(regionKey) { const region = REGIONS[regionKey]; if (!region) throw new Error(`未配置的地区渠道金额图:${regionKey}`); return function regionChannelAmountChart(rows, { width, height, view = 'all' }) { const periods = col(rows, 'period'); const barWidth = view === 'all' ? 48 : 80; const totalMax = Math.max(...rows.map((row) => row.total_revenue)); const axisMax = AMOUNT_NICE_MAX.find((max) => max >= totalMax * 1.02) ?? Math.ceil((totalMax * 1.2) / 100) * 100; // 绘图区像素高:画布高 − grid top 145 − grid bottom 100(两行脚注) const pxPerUnit = (height - LAYOUT.gridTop - 100) / axisMax; const rowOffsets = fanTinyLabelOffsets(rows, region.series, pxPerUnit); return { backgroundColor: '#fff', title: { ...titleStyle, text: `泡泡玛特${region.title}渠道收入金额(${financialViewLabel(view)})`, subtext: '堆叠柱为各渠道收入(亿元);矮段数值标注在柱体右侧,线上渠道按披露细分拆分', }, legend: standardLegend(region.series.map((key) => SERIES[key].name)), tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, textStyle: { fontFamily: FONT }, formatter: (params) => { const row = rows[periods.indexOf(params[0].axisValue)]; return `${params[0].axisValue} ${region.source}总收入 ${row.total_revenue.toFixed(2)} 亿元
${params .filter((item) => item.value !== null) .slice() .reverse() .map((item) => `${item.marker}${item.seriesName}: ${item.value.toFixed(1)} 亿元`) .join('
')}`; }, }, grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 100 }, xAxis: { ...standalonePeriodAxis, data: periods }, yAxis: { ...axisCommon, type: 'value', name: '渠道收入(亿元)', nameLocation: 'middle', nameGap: 56, nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, min: 0, max: axisMax, interval: axisMax % 4 === 0 ? axisMax / 4 : axisMax / 5, }, series: region.series.map((key, ci) => barSeries({ name: SERIES[key].name, data: rows.map((row, ri) => stackedSharePoint(row[key], { pxPerUnit, labelColor: SERIES[key].labelColor, dy: rowOffsets[ri][ci] ?? 0, })), color: SERIES[key].color, barWidth, stack: `channel-amount-${regionKey}`, label: { show: true, position: 'inside', fontSize: 11, fontFamily: FONT, fontWeight: 500, overflow: 'none', formatter: (point) => point.value.toFixed(1), }, emphasis: { focus: 'series' }, })), graphic: [ ...watermarks(width, height), footnote(region.footnote), ], }; }; } /** 分地区渠道收入占比:线上渠道拆分披露的细分平台,每根柱按当期该地区总收入归一化。 */ export function makeRegionChannelShareChart(regionKey) { const region = REGIONS[regionKey]; if (!region) throw new Error(`未配置的地区渠道占比图:${regionKey}`); return function regionChannelShareChart(rows, { width, height, view = 'all' }) { const periods = col(rows, 'period'); const barWidth = view === 'all' ? 48 : 80; // 绘图区像素高:画布高 − grid top 145 − grid bottom 100;轴固定 0–100% const pxPerUnit = (height - LAYOUT.gridTop - 100) / 100; const rowOffsets = fanTinyLabelOffsets(rows, region.series, pxPerUnit); return { backgroundColor: '#fff', title: { ...titleStyle, text: `泡泡玛特${region.title}渠道收入占比(${financialViewLabel(view)})`, subtext: '归一化堆叠:每根柱为当期该地区总收入的 100%;线上渠道按披露细分拆分', }, legend: standardLegend(region.series.map((key) => SERIES[key].name)), tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, textStyle: { fontFamily: FONT }, formatter: (params) => { const row = rows[periods.indexOf(params[0].axisValue)]; return `${params[0].axisValue} ${region.source}总收入 ${row.total_revenue.toFixed(2)} 亿元
${params .filter((item) => item.value !== null) .slice() .reverse() .map((item) => `${item.marker}${item.seriesName}: ${item.value.toFixed(1)}%`) .join('
')}`; }, }, grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 100 }, xAxis: { ...standalonePeriodAxis, data: periods }, yAxis: { ...axisCommon, type: 'value', min: 0, max: 100, interval: 20, axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' }, }, series: region.series.map((key, ci) => barSeries({ name: SERIES[key].name, data: rows.map((row, ri) => stackedSharePoint(row[key], { pxPerUnit, labelColor: SERIES[key].labelColor, dy: rowOffsets[ri][ci] ?? 0, })), color: SERIES[key].color, barWidth, stack: `channel-share-${regionKey}`, label: { show: true, position: 'inside', fontSize: 11, fontFamily: FONT, fontWeight: 500, formatter: (point) => `${point.value.toFixed(1)}%`, }, emphasis: { focus: 'series' }, })), graphic: [ ...watermarks(width, height), footnote(region.footnote), ], }; }; }