feat: 增加不同地区各渠道收入占比图
This commit is contained in:
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user