feat: 增加不同地区各渠道收入占比图

This commit is contained in:
R524809
2026-09-08 16:53:44 +08:00
parent 44c6437b64
commit bab02e1efd
25 changed files with 888 additions and 29 deletions
+174
View File
@@ -0,0 +1,174 @@
import {
PALETTE, FONT, categoryAxis, titleStyle,
standardLegend, footnote, watermarks, LAYOUT,
} from '../theme.js';
const STATE_LABELS = {
initial: '期初持有',
added: '新建仓',
held: '持续持有',
sold: '清仓',
};
const STATE_COLORS = {
initial: PALETTE.sand,
added: PALETTE.green,
sold: PALETTE.coral,
};
const SHORT_NAMES = {
'美国运通(American Express)': '美国运通',
'可口可乐(Coca-Cola)': '可口可乐',
'华特迪士尼(Walt Disney)': '迪士尼',
'房地美(Freddie Mac)': '房地美',
'吉列(Gillette)': '吉列',
'旅行家集团(Travelers Group)': '旅行家集团',
'华盛顿邮报(Washington Post)': '华盛顿邮报',
'富国银行(Wells Fargo)': '富国银行',
'大都会/ABC(Capital Cities/ABC)': '大都会/ABC',
'盖可保险(GEICO)': '盖可保险',
'甘尼特(Gannett)': '甘尼特',
'PNC 银行(PNC Bank)': 'PNC 银行',
'通用动力(General Dynamics)': '通用动力',
'健力士(Guinness)': '健力士',
"麦当劳(McDonald's)": '麦当劳',
};
function formatShares(value) {
return Number(value).toLocaleString('en-US');
}
function quantityChange(current, previous) {
if (!Number.isFinite(previous)) return '无可比上年数据';
const change = current - previous;
const sign = change > 0 ? '+' : '';
return `${sign}${formatShares(change)} 股`;
}
export function brkHoldingsChart(rows, { width, height }) {
const years = [...new Set(rows.map((row) => row.year))].sort((a, b) => a - b);
const holdingsByYear = new Map(years.map((year) => [year, new Map()]));
const firstYearByCompany = new Map();
const maxSharesByCompany = new Map();
rows.forEach((row) => {
holdingsByYear.get(row.year).set(row.company, row.shares);
firstYearByCompany.set(row.company, Math.min(firstYearByCompany.get(row.company) ?? row.year, row.year));
maxSharesByCompany.set(row.company, Math.max(maxSharesByCompany.get(row.company) ?? 0, row.shares));
});
const companies = [...firstYearByCompany.keys()].sort((a, b) => (
firstYearByCompany.get(a) - firstYearByCompany.get(b) || a.localeCompare(b, 'zh-CN')
));
const dataByState = { initial: [], added: [], held: [], sold: [] };
years.forEach((year, yearIndex) => {
const current = holdingsByYear.get(year);
const previous = yearIndex > 0 ? holdingsByYear.get(years[yearIndex - 1]) : new Map();
companies.forEach((company, companyIndex) => {
const shares = current.get(company);
const priorShares = previous.get(company);
let state;
if (Number.isFinite(shares)) {
state = yearIndex === 0 ? 'initial' : (Number.isFinite(priorShares) ? 'held' : 'added');
} else if (yearIndex > 0 && Number.isFinite(priorShares)) {
state = 'sold';
} else {
return;
}
const relativeSize = Number.isFinite(shares)
? Math.sqrt(shares / maxSharesByCompany.get(company))
: 1;
const color = state === 'held'
? `rgba(59, 110, 165, ${(0.38 + relativeSize * 0.62).toFixed(2)})`
: STATE_COLORS[state];
const labelColor = state === 'initial' || (state === 'held' && relativeSize < 0.55)
? PALETTE.ink
: '#fff';
dataByState[state].push({
value: [yearIndex, companyIndex, shares ?? 0],
shares,
priorShares,
state,
shortName: SHORT_NAMES[company] || company,
itemStyle: { color, borderColor: '#fff', borderWidth: 3 },
label: { color: labelColor },
});
});
});
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '伯克希尔主要持仓变动(1988–1997)',
subtext: '每格标注公司简称;持续持有的蓝色越深,表示相对持股数量越大',
},
legend: standardLegend([
STATE_LABELS.initial,
STATE_LABELS.added,
STATE_LABELS.held,
STATE_LABELS.sold,
], { selectedMode: false, itemGap: 20 }),
tooltip: {
trigger: 'item',
textStyle: { fontFamily: FONT },
formatter: (params) => {
const data = params.data;
const company = companies[data.value[1]];
const year = years[data.value[0]];
const lines = [`<b>${year} · ${company}</b>`, `状态:${STATE_LABELS[data.state]}`];
if (Number.isFinite(data.shares)) {
lines.push(`年末持股:${formatShares(data.shares)} 股`);
if (data.state !== 'initial') lines.push(`较上年:${quantityChange(data.shares, data.priorShares)}`);
} else {
lines.push(`上年末持股:${formatShares(data.priorShares)} 股`);
}
return lines.join('<br/>');
},
},
// ECharts 的 heatmap 组件要求声明 visualMap;实际颜色由每个状态单元格的 itemStyle 控制。
visualMap: {
show: false,
min: 0,
max: Math.max(...rows.map((row) => row.shares)),
dimension: 2,
inRange: { color: ['#ffffff'] },
},
grid: { left: 258, right: 48, top: LAYOUT.gridTop, bottom: 72 },
xAxis: {
...categoryAxis,
type: 'category',
data: years.map(String),
splitArea: { show: true, areaStyle: { color: ['#fbfbfa'] } },
},
yAxis: {
...categoryAxis,
type: 'category',
inverse: true,
data: companies,
axisLabel: { ...categoryAxis.axisLabel, fontSize: 12, margin: 14 },
splitArea: { show: true, areaStyle: { color: ['#fbfbfa'] } },
},
series: Object.entries(dataByState).map(([state, data]) => ({
name: STATE_LABELS[state],
type: 'heatmap',
data,
label: {
show: true,
position: 'inside',
fontFamily: FONT,
fontSize: 12,
fontWeight: 600,
formatter: (params) => params.data.shortName,
},
emphasis: { itemStyle: { borderColor: PALETTE.ink, borderWidth: 2 } },
})),
graphic: [
...watermarks(width, height),
footnote('绿色=相对上一年新增;红色=相对上一年清仓;蓝色深浅=相对持股数量 | @思考的Joey'),
],
};
}
+7
View File
@@ -26,6 +26,7 @@ import { workingCapitalDaysChart } from './ppmt_working_capital_days.js';
import { expenseStructureChart } from './ppmt_expense_structure.js';
import { channelRevenueChart } from './ppmt_channel_revenue.js';
import { channelShareChart } from './ppmt_channel_share.js';
import { makeRegionChannelShareChart } from './ppmt_channel_share_region.js';
import { storesRetailChart } from './ppmt_stores_retail.js';
import { storesRobotChart } from './ppmt_stores_robot.js';
import { regionShareChart } from './ppmt_region_share.js';
@@ -33,6 +34,7 @@ import { inventoryBalanceChart } from './ppmt_inventory.js';
import { storesRetailShareChart } from './ppmt_stores_retail_share.js';
import { retailStoreRevenueChart } from './ppmt_retail_store_revenue.js';
import { robotStoreRevenueChart } from './ppmt_robot_store_revenue.js';
import { brkHoldingsChart } from './brk_holdings.js';
/** 图表构建函数注册表。manifest 只引用这里的 key,不直接引用 JS 函数。 */
export const chartRegistry = {
@@ -64,6 +66,10 @@ export const chartRegistry = {
'ppmt-expense-structure': expenseStructureChart,
'ppmt-channel-revenue': channelRevenueChart,
'ppmt-channel-share': channelShareChart,
'ppmt-channel-share-china': makeRegionChannelShareChart('china'),
'ppmt-channel-share-apac': makeRegionChannelShareChart('apac'),
'ppmt-channel-share-americas': makeRegionChannelShareChart('americas'),
'ppmt-channel-share-europe': makeRegionChannelShareChart('europe'),
'ppmt-stores-retail': storesRetailChart,
'ppmt-stores-robot': storesRobotChart,
'ppmt-region-share': regionShareChart,
@@ -71,6 +77,7 @@ export const chartRegistry = {
'ppmt-stores-retail-share': storesRetailShareChart,
'ppmt-retail-store-revenue': retailStoreRevenueChart,
'ppmt-robot-store-revenue': robotStoreRevenueChart,
'brk-holdings': brkHoldingsChart,
};
export function getChartBuilder(key) {
+11 -14
View File
@@ -1,7 +1,7 @@
import { col } from '../csv.js';
import {
CHANNEL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend,
financialViewLabel, barSeries, footnote, watermarks, LAYOUT,
financialViewLabel, barSeries, stackedSharePoint, fanTinyLabelOffsets, footnote, watermarks, LAYOUT,
} from '../theme.js';
const CHANNELS = [
@@ -11,21 +11,14 @@ const CHANNELS = [
{ key: 'wholesale', name: '批发及其他', color: CHANNEL_COLORS.wholesale, labelColor: PALETTE.ink },
];
function sharePoint(value, labelColor) {
if (!Number.isFinite(value) || value === 0) return null;
// 3.5% 以下的段放不下 11px 段内标签;当前数据最小为 3.7%(2025H2 机器人商店)
return {
value,
label: value >= 3.5
? { position: 'inside', color: labelColor }
: { show: false },
};
}
/** 泡泡玛特全渠道收入占比:全部地区合并,每根柱按当期总收入归一化为 100%。 */
export function channelShareChart(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 seriesKeys = CHANNELS.map((item) => item.key);
const rowOffsets = fanTinyLabelOffsets(rows, seriesKeys, pxPerUnit);
return {
backgroundColor: '#fff',
@@ -59,9 +52,13 @@ export function channelShareChart(rows, { width, height, view = 'all' }) {
interval: 20,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
},
series: CHANNELS.map((item) => barSeries({
series: CHANNELS.map((item, ci) => barSeries({
name: item.name,
data: rows.map((row) => sharePoint(row[item.key], item.labelColor)),
data: rows.map((row, ri) => stackedSharePoint(row[item.key], {
pxPerUnit,
labelColor: item.labelColor,
dy: rowOffsets[ri][ci] ?? 0,
})),
color: item.color,
barWidth,
stack: 'channel-share',
+122
View File
@@ -0,0 +1,122 @@
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),
],
};
};
}
+11 -13
View File
@@ -1,7 +1,7 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend,
financialViewLabel, barSeries, footnote, watermarks, LAYOUT,
financialViewLabel, barSeries, stackedSharePoint, fanTinyLabelOffsets, footnote, watermarks, LAYOUT,
} from '../theme.js';
/** 泡泡玛特分地区零售门店占比:每根柱标准化为各期末全球门店数的 100%。 */
@@ -13,19 +13,13 @@ const REGIONS = [
{ key: 'other', name: '其他', color: PALETTE.sand, labelColor: PALETTE.ink },
];
function sharePoint(value, labelColor) {
if (!Number.isFinite(value) || value === 0) return null;
return {
value,
label: value >= 5
? { position: 'inside', color: labelColor }
: { show: false },
};
}
export function storesRetailShareChart(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 seriesKeys = REGIONS.map((item) => item.key);
const rowOffsets = fanTinyLabelOffsets(rows, seriesKeys, pxPerUnit);
return {
backgroundColor: '#fff',
@@ -59,9 +53,13 @@ export function storesRetailShareChart(rows, { width, height, view = 'all' }) {
interval: 20,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
},
series: REGIONS.map((item) => barSeries({
series: REGIONS.map((item, ci) => barSeries({
name: item.name,
data: rows.map((row) => sharePoint(row[item.key], item.labelColor)),
data: rows.map((row, ri) => stackedSharePoint(row[item.key], {
pxPerUnit,
labelColor: item.labelColor,
dy: rowOffsets[ri][ci] ?? 0,
})),
color: item.color,
barWidth,
stack: 'stores-retail-share',
+61
View File
@@ -291,6 +291,67 @@ export function barSeries({ name, data, color, barWidth, formatter, ...rest }) {
};
}
/**
* 归一化堆叠占比图的矮段标签外置偏移。
* 段高不足(< minInsidePx 像素)放不下段内标签时,标签标到柱体右侧;
* 同一根柱里有多个矮段时,柱侧标签自下而上按 gap 像素扇形排开避免重叠
* (先例:ppmt_channel_revenue 的 fanOutsideLabels、ppmt_region_share 的柱侧标签)。
* 返回 { 行序号: { 系列序号: dy } },dy 为 ECharts label.offset 的纵向偏移。
*/
export function fanTinyLabelOffsets(rows, seriesKeys, pxPerUnit, { minInsidePx = 15, gap = 13 } = {}) {
return rows.map((row) => {
const centers = [];
let cum = 0;
seriesKeys.forEach((key) => {
const value = Number.isFinite(row[key]) ? row[key] : 0;
centers.push((cum + value / 2) * pxPerUnit);
cum += value;
});
const tinyIdx = seriesKeys
.map((_, index) => index)
.filter((index) => Number.isFinite(row[seriesKeys[index]])
&& row[seriesKeys[index]] > 0
&& row[seriesKeys[index]] * pxPerUnit < minInsidePx);
if (tinyIdx.length === 0) return {};
const ys = tinyIdx.map((index) => centers[index]);
for (let k = 1; k < ys.length; k += 1) ys[k] = Math.max(ys[k], ys[k - 1] + gap);
if (tinyIdx.length === seriesKeys.length) {
// 整根柱都是矮段:扇形整体对中到柱身中部,避免标签飘离柱顶
const shift = cum * pxPerUnit / 2 - (ys[0] + ys[ys.length - 1]) / 2;
for (let k = 0; k < ys.length; k += 1) ys[k] += shift;
if (ys[0] < 2) {
for (let k = 0; k < ys.length; k += 1) ys[k] += 2 - ys[0];
}
}
const offsets = {};
tinyIdx.forEach((index, k) => { offsets[index] = centers[index] - ys[k]; });
return offsets;
});
}
/**
* 归一化堆叠占比图的逐点标签:段高够时段内显示(用系列的段内配色),
* 矮段外置到柱体右侧(深字白描边),保证每个分类的占比数值都可见。
* 必须配合 fanTinyLabelOffsets 计算的 dy 使用;value 为空或 0 时不渲染该段。
*/
export function stackedSharePoint(value, { pxPerUnit, labelColor, dy = 0, minInsidePx = 15 }) {
if (!Number.isFinite(value) || value === 0) return null;
if (value * pxPerUnit >= minInsidePx) {
return { value, label: { position: 'inside', color: labelColor } };
}
return {
value,
label: {
position: 'right',
color: PALETTE.ink,
distance: 3,
offset: [0, dy],
textBorderColor: '#fff',
textBorderWidth: 2,
},
};
}
/** 底部注释 */
export function footnote(text) {
return {