Files
smart-fin-chart/js/charts/ppmt_channel_share_region.js
T

123 lines
5.7 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',
},
};
/** 分地区渠道收入占比:线上渠道拆分披露的细分平台,每根柱按当期该地区总收入归一化。 */
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),
],
};
};
}