Files
smart-fin-chart/js/charts/ppmt_channel_share_region.js
T
2026-09-09 15:31:05 +08:00

206 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
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 `<b>${params[0].axisValue}</b> ${region.source}总收入 ${row.total_revenue.toFixed(2)} 亿元<br/>${params
.filter((item) => item.value !== null)
.slice()
.reverse()
.map((item) => `${item.marker}${item.seriesName}: ${item.value.toFixed(1)} 亿元`)
.join('<br/>')}`;
},
},
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 `<b>${params[0].axisValue}</b> ${region.source}总收入 ${row.total_revenue.toFixed(2)} 亿元<br/>${params
.filter((item) => item.value !== null)
.slice()
.reverse()
.map((item) => `${item.marker}${item.seriesName}: ${item.value.toFixed(1)}%`)
.join('<br/>')}`;
},
},
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),
],
};
};
}