feat: 优化泡泡玛特的图表
This commit is contained in:
@@ -8,7 +8,6 @@ import { profitChart } from './ppmt_profit.js';
|
||||
import { ipChart } from './ppmt_ip.js';
|
||||
import { categoryChart } from './ppmt_category.js';
|
||||
import { sankeyChart } from './ppmt_sankey.js';
|
||||
import { regionChannelChart } from './ppmt_region_channel.js';
|
||||
import { regionChannelYoyChart } from './ppmt_region_yoy.js';
|
||||
import { regionChannelYoyTimeseriesChart } from './ppmt_region_channel_yoy_timeseries.js';
|
||||
import { ppmtGrowthChart } from './ppmt_growth.js';
|
||||
@@ -60,7 +59,6 @@ export const chartRegistry = {
|
||||
'ppmt-ip': ipChart,
|
||||
'ppmt-category': categoryChart,
|
||||
'ppmt-sankey': sankeyChart,
|
||||
'ppmt-region-channel': regionChannelChart,
|
||||
'ppmt-region-yoy': regionChannelYoyChart,
|
||||
'ppmt-region-yoy-timeseries': regionChannelYoyTimeseriesChart,
|
||||
'ppmt-growth': ppmtGrowthChart,
|
||||
|
||||
+55
-36
@@ -1,16 +1,45 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
|
||||
financialViewLabel,
|
||||
financialViewLabel, niceAxisMax, fanTinyLabelOffsets, stackedSharePoint,
|
||||
lineSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
const KEYS = ['cash_like', 'receivables', 'investment', 'operating_assets'];
|
||||
const NAMES = {
|
||||
cash_like: '类现金',
|
||||
receivables: '应收账款',
|
||||
investment: '投资资产',
|
||||
operating_assets: '经营类资产',
|
||||
};
|
||||
const COLORS = {
|
||||
cash_like: PALETTE.blue,
|
||||
receivables: PALETTE.orange,
|
||||
investment: PALETTE.purple,
|
||||
operating_assets: PALETTE.sage,
|
||||
};
|
||||
// 段内标签文字色:浅色段(经营类资产灰绿)用深字,深色段用白字
|
||||
const INSIDE_LABEL_COLORS = {
|
||||
cash_like: '#fff',
|
||||
receivables: '#fff',
|
||||
investment: '#fff',
|
||||
operating_assets: PALETTE.ink,
|
||||
};
|
||||
|
||||
/**
|
||||
* 泡泡玛特资产结构演变图
|
||||
* 堆叠柱状图展示资产配置,折线图展示总资产增长
|
||||
* 堆叠柱状图展示资产配置(每段标数值),折线图展示总资产增长
|
||||
*/
|
||||
export function assetsChart(rows, { width, height, view = 'all' }) {
|
||||
const periods = col(rows, 'period');
|
||||
const totalMax = Math.max(...rows.map((r) => r.total_assets));
|
||||
// 轴上限随数据走(2025H2 总资产已 321 亿),矮段像素判定才有确定基准
|
||||
const axisMax = niceAxisMax(totalMax);
|
||||
const interval = axisMax % 7 === 0 ? axisMax / 7 : axisMax / 5;
|
||||
// 绘图区像素高:画布高 − grid top 145 − grid bottom 90
|
||||
const pxPerUnit = (height - LAYOUT.gridTop - 90) / axisMax;
|
||||
// 早期年份应收/投资不足 1 亿,矮段标签外置到柱体右侧扇形排开
|
||||
const rowOffsets = fanTinyLabelOffsets(rows, KEYS, pxPerUnit);
|
||||
|
||||
return {
|
||||
backgroundColor: '#fff',
|
||||
@@ -22,7 +51,7 @@ export function assetsChart(rows, { width, height, view = 'all' }) {
|
||||
legend: {
|
||||
...legendStyle,
|
||||
top: LAYOUT.legendTop,
|
||||
data: ['类现金', '应收账款', '投资资产', '经营类资产', '资产总计'],
|
||||
data: [...KEYS.map((key) => NAMES[key]), '资产总计'],
|
||||
},
|
||||
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
|
||||
tooltip: {
|
||||
@@ -40,8 +69,8 @@ export function assetsChart(rows, { width, height, view = 'all' }) {
|
||||
nameGap: 52,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: 0,
|
||||
max: 350,
|
||||
interval: 50,
|
||||
max: axisMax,
|
||||
interval,
|
||||
},
|
||||
{
|
||||
...axisCommon,
|
||||
@@ -52,44 +81,34 @@ export function assetsChart(rows, { width, height, view = 'all' }) {
|
||||
nameRotate: -90,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: 0,
|
||||
max: 350,
|
||||
interval: 50,
|
||||
max: axisMax,
|
||||
interval,
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '类现金',
|
||||
...KEYS.map((key, ci) => ({
|
||||
name: NAMES[key],
|
||||
type: 'bar',
|
||||
stack: 'assets',
|
||||
data: col(rows, 'cash_like'),
|
||||
itemStyle: { color: PALETTE.blue },
|
||||
// 每段都标数值:矮段塞不进字的,外置到柱体右侧(机制同渠道金额图)
|
||||
data: rows.map((row, ri) => stackedSharePoint(row[key], {
|
||||
pxPerUnit,
|
||||
labelColor: INSIDE_LABEL_COLORS[key],
|
||||
dy: rowOffsets[ri][ci] ?? 0,
|
||||
})),
|
||||
itemStyle: { color: COLORS[key] },
|
||||
emphasis: { focus: 'series' },
|
||||
},
|
||||
{
|
||||
name: '应收账款',
|
||||
type: 'bar',
|
||||
stack: 'assets',
|
||||
data: col(rows, 'receivables'),
|
||||
itemStyle: { color: PALETTE.orange },
|
||||
emphasis: { focus: 'series' },
|
||||
},
|
||||
{
|
||||
name: '投资资产',
|
||||
type: 'bar',
|
||||
stack: 'assets',
|
||||
data: col(rows, 'investment'),
|
||||
itemStyle: { color: PALETTE.purple },
|
||||
emphasis: { focus: 'series' },
|
||||
},
|
||||
{
|
||||
name: '经营类资产',
|
||||
type: 'bar',
|
||||
stack: 'assets',
|
||||
data: col(rows, 'operating_assets'),
|
||||
itemStyle: { color: PALETTE.sage },
|
||||
emphasis: { focus: 'series' },
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
color: INSIDE_LABEL_COLORS[key],
|
||||
fontSize: 11,
|
||||
fontFamily: FONT,
|
||||
overflow: 'none',
|
||||
formatter: (p) => `${p.value.toFixed(1)}`,
|
||||
},
|
||||
})),
|
||||
{
|
||||
...lineSeries({
|
||||
name: '资产总计',
|
||||
|
||||
+130
-77
@@ -1,66 +1,150 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
|
||||
financialViewDisclosure, financialViewLabel,
|
||||
footnote, watermarks, LAYOUT,
|
||||
financialViewDisclosure, financialViewLabel, niceAxisMax,
|
||||
fanTinyLabelOffsets, stackedSharePoint,
|
||||
lineSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
/**
|
||||
* 泡泡玛特品类收入结构图
|
||||
* 堆叠柱状图展示各品类的收入占比和增长
|
||||
* 堆叠柱展示各品类收入;折线(右轴)展示各品类收入同比,折线与柱同色便于对应。
|
||||
* 同比为衍生指标:当期 ÷ 上年同期 − 1(H2 对 H2 单半年、FY 对 FY 累计,同期对同期),
|
||||
* 2024 年各期无上年基数,不计算。
|
||||
*/
|
||||
// 同比折线用柱色的同色系深色变体:压在柱体上能区分开,又靠色相保持与柱的对应
|
||||
const CATEGORIES = [
|
||||
{ name: '毛绒', key: 'plush', color: PALETTE.coral, lineColor: PALETTE.red },
|
||||
{ name: '手办', key: 'figure', color: PALETTE.blue, lineColor: PALETTE.navy },
|
||||
// 色板没有深紫,按 MEGA 紫加深
|
||||
{ name: 'MEGA', key: 'mega', color: PALETTE.purple, lineColor: '#5E2A79' },
|
||||
{ name: '衍生品及其他', key: 'other', color: PALETTE.sage, lineColor: PALETTE.green },
|
||||
];
|
||||
|
||||
export function categoryChart(rows, { width, height, view = 'all' }) {
|
||||
// 过滤掉表头行("品类"那一行)
|
||||
const dataRows = rows.filter(r => r.period && r.period !== '品类');
|
||||
const periods = col(dataRows, 'period');
|
||||
const num = (r, k) => (Number.isFinite(r[k]) ? r[k] : null);
|
||||
|
||||
// 上年同期的期间下标(H1↔H1、H2↔H2、FY↔FY),无则 -1
|
||||
const priorIndex = periods.map((p) => {
|
||||
if (!/^20\d{2}(H1|H2|FY)$/.test(p)) return -1;
|
||||
const prev = p.replace(/^20(\d{2})/, (_, yy) => `20${String(Number(yy) - 1).padStart(2, '0')}`);
|
||||
return periods.indexOf(prev);
|
||||
});
|
||||
const yoyByCat = Object.fromEntries(CATEGORIES.map((c) => [
|
||||
c.key,
|
||||
periods.map((_, i) => {
|
||||
const j = priorIndex[i];
|
||||
const cur = num(dataRows[i], c.key);
|
||||
const prev = j >= 0 ? num(dataRows[j], c.key) : null;
|
||||
if (cur == null || prev == null || prev === 0) return null;
|
||||
return (cur / prev - 1) * 100;
|
||||
}),
|
||||
]));
|
||||
const yoyValues = CATEGORIES.flatMap((c) => yoyByCat[c.key]).filter(Number.isFinite);
|
||||
const yoyMax = Math.max(100, Math.ceil(Math.max(...yoyValues) / 100) * 100);
|
||||
const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 100) * 100);
|
||||
// 右轴固定整百刻度:范围 ≤500 时 100% 一步,否则 200% 一步,刻度数控制在 7 以内
|
||||
const yoyInterval = yoyMax - yoyMin <= 500 ? 100 : 200;
|
||||
|
||||
// 多折线标签避让(同 ppmt_margins labeledData):比较上下间隙,朝更大的一侧放
|
||||
const yoyLabelPos = periods.map((_, i) => {
|
||||
const ranked = CATEGORIES
|
||||
.map((c) => yoyByCat[c.key][i])
|
||||
.filter(Number.isFinite)
|
||||
.sort((a, b) => b - a);
|
||||
return Object.fromEntries(CATEGORIES.map((c) => {
|
||||
const v = yoyByCat[c.key][i];
|
||||
if (!Number.isFinite(v)) return [c.key, 'top'];
|
||||
const rank = ranked.indexOf(v);
|
||||
const gapUp = rank > 0 ? ranked[rank - 1] - v : Infinity;
|
||||
const gapDown = rank < ranked.length - 1 ? v - ranked[rank + 1] : Infinity;
|
||||
return [c.key, gapUp >= gapDown ? 'top' : 'bottom'];
|
||||
}));
|
||||
});
|
||||
|
||||
const totals = periods.map((_, i) => CATEGORIES.reduce((s, c) => s + (num(dataRows[i], c.key) ?? 0), 0));
|
||||
// 轴上限随数据走:2025FY 合计已 371.2 亿,写死 250 会把柱子顶出绘图区
|
||||
const axisMax = niceAxisMax(Math.max(...totals));
|
||||
const amountInterval = axisMax % 4 === 0 ? axisMax / 4 : axisMax / 5;
|
||||
// 绘图区像素高:画布高 − grid top 145 − grid bottom 90
|
||||
const pxPerUnit = (height - LAYOUT.gridTop - 90) / axisMax;
|
||||
const rowOffsets = fanTinyLabelOffsets(dataRows, CATEGORIES.map((c) => c.key), pxPerUnit);
|
||||
|
||||
return {
|
||||
backgroundColor: '#fff',
|
||||
title: {
|
||||
...titleStyle,
|
||||
text: `泡泡玛特品类收入结构(${financialViewLabel(view)})`,
|
||||
subtext: ['单位:亿元;H2为下半年单期', financialViewDisclosure(view)].filter(Boolean).join('\n'),
|
||||
subtext: ['柱为收入(亿元,左轴);折线为各品类收入同比(右轴);H2为下半年单期',
|
||||
financialViewDisclosure(view)].filter(Boolean).join('\n'),
|
||||
},
|
||||
// 图例柱与同比线交错排列,同品类的两项相邻
|
||||
legend: {
|
||||
...legendStyle,
|
||||
top: LAYOUT.legendTop,
|
||||
data: ['毛绒', '手办', 'MEGA', '衍生品及其他'],
|
||||
data: CATEGORIES.flatMap((c) => [c.name, `${c.name}同比`]),
|
||||
},
|
||||
grid: { left: 78, right: 56, top: LAYOUT.gridTop, bottom: 90 },
|
||||
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
textStyle: { fontFamily: FONT },
|
||||
formatter: (params) => {
|
||||
const bars = params.filter((p) => p.seriesType === 'bar' && Number.isFinite(p.value));
|
||||
const lines = params.filter((p) => p.seriesType === 'line' && Number.isFinite(p.value));
|
||||
const total = bars.reduce((s, p) => s + p.value, 0);
|
||||
let result = `<b>${params[0].axisValue}</b><br/>`;
|
||||
let total = 0;
|
||||
params.forEach(p => {
|
||||
total += p.value;
|
||||
result += `${p.marker}${p.seriesName}: ${p.value.toFixed(2)} 亿元<br/>`;
|
||||
bars.forEach((p) => { result += `${p.marker}${p.seriesName}: ${p.value.toFixed(2)} 亿元<br/>`; });
|
||||
result += `<b>合计: ${total.toFixed(2)} 亿元</b><br/>`;
|
||||
lines.forEach((p) => {
|
||||
result += `${p.marker}${p.seriesName}: ${p.value > 0 ? '+' : ''}${p.value.toFixed(1)}%<br/>`;
|
||||
});
|
||||
result += `<b>合计: ${total.toFixed(2)} 亿元</b>`;
|
||||
return result;
|
||||
},
|
||||
},
|
||||
xAxis: { ...standalonePeriodAxis, data: periods },
|
||||
yAxis: {
|
||||
...axisCommon,
|
||||
type: 'value',
|
||||
name: '收入(亿元)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 52,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: 0,
|
||||
max: 250,
|
||||
interval: 50,
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
...axisCommon,
|
||||
type: 'value',
|
||||
name: '收入(亿元)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 52,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: 0,
|
||||
max: axisMax,
|
||||
interval: amountInterval,
|
||||
},
|
||||
{
|
||||
...axisCommon,
|
||||
type: 'value',
|
||||
name: '收入同比(%)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 56,
|
||||
nameRotate: -90,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: yoyMin,
|
||||
max: yoyMax,
|
||||
interval: yoyInterval,
|
||||
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '毛绒',
|
||||
...CATEGORIES.map((c, ci) => ({
|
||||
name: c.name,
|
||||
type: 'bar',
|
||||
stack: 'category',
|
||||
data: col(dataRows, 'plush'),
|
||||
itemStyle: { color: PALETTE.coral },
|
||||
// 每段都标数值:矮段外置到柱体右侧,机制同渠道金额图
|
||||
data: dataRows.map((row, ri) => stackedSharePoint(row[c.key], {
|
||||
pxPerUnit,
|
||||
labelColor: '#fff',
|
||||
dy: rowOffsets[ri][ci] ?? 0,
|
||||
})),
|
||||
itemStyle: { color: c.color },
|
||||
emphasis: { focus: 'series' },
|
||||
label: {
|
||||
show: true,
|
||||
@@ -68,61 +152,30 @@ export function categoryChart(rows, { width, height, view = 'all' }) {
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontFamily: FONT,
|
||||
formatter: (p) => (p.value > 15 ? p.value.toFixed(1) : ''),
|
||||
overflow: 'none',
|
||||
formatter: (p) => `${p.value.toFixed(1)}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '手办',
|
||||
type: 'bar',
|
||||
stack: 'category',
|
||||
data: col(dataRows, 'figure'),
|
||||
itemStyle: { color: PALETTE.blue },
|
||||
emphasis: { focus: 'series' },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontFamily: FONT,
|
||||
formatter: (p) => (p.value > 15 ? p.value.toFixed(1) : ''),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'MEGA',
|
||||
type: 'bar',
|
||||
stack: 'category',
|
||||
data: col(dataRows, 'mega'),
|
||||
itemStyle: { color: PALETTE.purple },
|
||||
emphasis: { focus: 'series' },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontFamily: FONT,
|
||||
formatter: (p) => (p.value > 8 ? p.value.toFixed(1) : ''),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '衍生品及其他',
|
||||
type: 'bar',
|
||||
stack: 'category',
|
||||
data: col(dataRows, 'other'),
|
||||
itemStyle: { color: PALETTE.sage },
|
||||
emphasis: { focus: 'series' },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontFamily: FONT,
|
||||
formatter: (p) => (p.value > 8 ? p.value.toFixed(1) : ''),
|
||||
},
|
||||
},
|
||||
})),
|
||||
...CATEGORIES.map((c) => ({
|
||||
...lineSeries({
|
||||
name: `${c.name}同比`,
|
||||
data: yoyByCat[c.key],
|
||||
color: c.lineColor,
|
||||
formatter: (p) => `${p.value.toFixed(1)}%`,
|
||||
width: 2.5,
|
||||
}),
|
||||
yAxisIndex: 1,
|
||||
z: 10,
|
||||
// label.position 不接受函数,逐点覆盖(避让规则见 yoyLabelPos)
|
||||
data: yoyByCat[c.key].map((v, i) => (Number.isFinite(v)
|
||||
? { value: v, label: { position: yoyLabelPos[i][c.key], distance: 10 } }
|
||||
: null)),
|
||||
labelLayout: { hideOverlap: true },
|
||||
})),
|
||||
],
|
||||
graphic: [
|
||||
...watermarks(width, height),
|
||||
footnote('数据来源:泡泡玛特财报 | @Joey知行记'),
|
||||
footnote('数据来源:泡泡玛特财报;同比=当期÷上年同期−1\n2024年各期无上年基数,不计算同比 | @Joey知行记'),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
CHANNEL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
|
||||
financialViewDisclosure, financialViewLabel,
|
||||
financialViewDisclosure, financialViewLabel, niceAxisMax,
|
||||
lineSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
@@ -23,14 +23,12 @@ const INSIDE_LABEL_COLORS = {
|
||||
|
||||
const num = (value) => (Number.isFinite(value) ? value : null);
|
||||
|
||||
// 轴上限取整齐刻度,柱高比例确定后,段标签的段内/外置可按像素判断
|
||||
const NICE_MAX = [20, 25, 50, 60, 80, 100, 120, 150, 200, 250, 300, 400, 500, 600, 800, 1000];
|
||||
// 轴上限按数据最大值取整齐刻度,柱高比例确定后,段标签的段内/外置可按像素判断
|
||||
const MIN_INSIDE_PX = 13; // 低于约 13px 的段放不下 10px 段内标签
|
||||
|
||||
export function channelRevenueChart(rows, { width, height, view = 'all' }) {
|
||||
const totalMax = Math.max(...rows.map((row) => num(row.total_revenue) || 0));
|
||||
const axisMax = NICE_MAX.find((max) => max >= totalMax * 1.02)
|
||||
?? Math.ceil((totalMax * 1.2) / 100) * 100;
|
||||
const axisMax = niceAxisMax(totalMax);
|
||||
const yoyValues = col(rows, 'revenue_yoy').filter(Number.isFinite);
|
||||
// 全部/半年视图存在 2022H2 的负增长,右轴需包含负区间,避免折线被 0 轴裁切。
|
||||
const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 50) * 50);
|
||||
|
||||
@@ -1,12 +1,49 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
|
||||
financialViewDisclosure, financialViewLabel,
|
||||
financialViewDisclosure, financialViewLabel, niceAxisMax, stackedSharePoint,
|
||||
lineSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
/**
|
||||
* 混合正负堆叠的矮段标签外置偏移。
|
||||
* ECharts 对负值段自 0 向下堆叠,theme 的 fanTinyLabelOffsets 按全正累计
|
||||
* 算不出正确中心,这里按符号分两摞累计(屏幕坐标 y 向下为正),
|
||||
* 矮段(塞不进段内文字的)标签扇形排开在柱体右侧。
|
||||
*/
|
||||
function expenseFanOffsets(rows, keys, pxPerUnit, { minInsidePx = 15, gap = 13 } = {}) {
|
||||
return rows.map((row) => {
|
||||
let posCum = 0;
|
||||
let negCum = 0;
|
||||
const centers = {};
|
||||
keys.forEach((k) => {
|
||||
const v = Number.isFinite(row[k]) ? row[k] : 0;
|
||||
const mid = v >= 0 ? posCum + v / 2 : negCum + v / 2;
|
||||
centers[k] = -mid * pxPerUnit;
|
||||
if (v >= 0) posCum += v; else negCum += v;
|
||||
});
|
||||
const tiny = keys.filter((k) => Number.isFinite(row[k]) && row[k] !== 0
|
||||
&& Math.abs(row[k]) * pxPerUnit < minInsidePx);
|
||||
if (!tiny.length) return {};
|
||||
const ys = tiny.map((k) => centers[k]);
|
||||
for (let i = 1; i < ys.length; i += 1) ys[i] = Math.max(ys[i], ys[i - 1] + gap);
|
||||
const offsets = {};
|
||||
tiny.forEach((k, i) => { offsets[k] = centers[k] - ys[i]; });
|
||||
return offsets;
|
||||
});
|
||||
}
|
||||
|
||||
/** 泡泡玛特期间费用结构:观察销售投入、管理成本与费用率的规模效应。 */
|
||||
export function expenseStructureChart(rows, { width, height, view = 'all' }) {
|
||||
const keys = ['sales_expense', 'admin_expense', 'financial_expense'];
|
||||
const posStackMax = Math.max(...rows.map((r) => keys.reduce((s, k) => s + Math.max(r[k] ?? 0, 0), 0)));
|
||||
const negMin = Math.min(...rows.map((r) => keys.reduce((s, k) => s + Math.min(r[k] ?? 0, 0), 0)));
|
||||
// 轴范围随数据走并取整刻度,保证矮段像素判定(pxPerUnit)有确定基准
|
||||
const axisMax = niceAxisMax(posStackMax);
|
||||
const axisMin = Math.min(0, Math.floor(negMin / 10) * 10);
|
||||
const pxPerUnit = (height - LAYOUT.gridTop - 90) / (axisMax - axisMin);
|
||||
const rowOffsets = expenseFanOffsets(rows, keys, pxPerUnit);
|
||||
|
||||
return {
|
||||
backgroundColor: '#fff',
|
||||
title: {
|
||||
@@ -35,6 +72,9 @@ export function expenseStructureChart(rows, { width, height, view = 'all' }) {
|
||||
nameLocation: 'middle',
|
||||
nameGap: 52,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: axisMin,
|
||||
max: axisMax,
|
||||
interval: 10,
|
||||
},
|
||||
{
|
||||
...axisCommon,
|
||||
@@ -52,9 +92,34 @@ export function expenseStructureChart(rows, { width, height, view = 'all' }) {
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{ name: '销售费用', type: 'bar', stack: 'expenses', data: col(rows, 'sales_expense'), itemStyle: { color: PALETTE.orange } },
|
||||
{ name: '管理费用', type: 'bar', stack: 'expenses', data: col(rows, 'admin_expense'), itemStyle: { color: PALETTE.purple } },
|
||||
{ name: '财务费用(净额)', type: 'bar', stack: 'expenses', data: col(rows, 'financial_expense'), itemStyle: { color: PALETTE.blue } },
|
||||
...keys.map((key, ci) => ({
|
||||
name: { sales_expense: '销售费用', admin_expense: '管理费用', financial_expense: '财务费用(净额)' }[key],
|
||||
type: 'bar',
|
||||
stack: 'expenses',
|
||||
// 每段都标数值:矮段(含负值段)外置到柱体右侧,见 expenseFanOffsets
|
||||
data: rows.map((row, ri) => stackedSharePoint(row[key], {
|
||||
pxPerUnit,
|
||||
labelColor: '#fff',
|
||||
dy: rowOffsets[ri][key] ?? 0,
|
||||
})),
|
||||
itemStyle: { color: [PALETTE.orange, PALETTE.purple, PALETTE.blue][ci] },
|
||||
emphasis: { focus: 'series' },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontFamily: FONT,
|
||||
fontWeight: 500,
|
||||
overflow: 'none',
|
||||
// 财务费用净额多在 ±0.1 亿内,一位小数会显示成 ±0.0,此时退到两位小数
|
||||
formatter: (p) => {
|
||||
const t = p.value.toFixed(1);
|
||||
if (t === '0.0' || t === '-0.0') return Math.abs(p.value) < 0.005 ? '0.0' : p.value.toFixed(2);
|
||||
return t;
|
||||
},
|
||||
},
|
||||
})),
|
||||
{
|
||||
...lineSeries({
|
||||
name: '四项费用率',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, legendStyle,
|
||||
financialViewDisclosure, financialViewLabel, shortStandalonePeriod,
|
||||
financialViewDisclosure, financialViewLabel, shortStandalonePeriod, niceAxisMax,
|
||||
lineSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
@@ -39,6 +39,10 @@ export function ipChart(rows, { width, height, view = 'all' }) {
|
||||
});
|
||||
});
|
||||
|
||||
// 轴上限随数据走(THE MONSTERS 2025 已超 140 亿),写死会把爆款折线顶出绘图区
|
||||
const values = rows.flatMap((row) => periods.map((p) => row[p])).filter(Number.isFinite);
|
||||
const axisMax = niceAxisMax(Math.max(...values));
|
||||
|
||||
return {
|
||||
backgroundColor: '#fff',
|
||||
title: {
|
||||
@@ -74,8 +78,8 @@ export function ipChart(rows, { width, height, view = 'all' }) {
|
||||
nameGap: 52,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: 0,
|
||||
max: 100,
|
||||
interval: 20,
|
||||
max: axisMax,
|
||||
interval: axisMax % 4 === 0 ? axisMax / 4 : axisMax / 5,
|
||||
},
|
||||
series,
|
||||
graphic: [
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
/**
|
||||
* 泡泡玛特主要 IP 收入占比(2026H1)
|
||||
* 饼图(非环图),图例放在右侧:每项上行 IP 名、下行小字显示收入和占比。
|
||||
* 扇区外直接标注 IP 名 + 数值(两行)。源:ppmt.xlsx「IP收入和占比」2026H1 列,
|
||||
* 扇区外直接标注 IP 名 + 数值/占比(两行),IP 名与扇区同色。源:ppmt.xlsx「IP收入和占比」2026H1 列,
|
||||
* HACIPUPU、BUNNY、PUCKY 等该期无数据的 IP 不列入。
|
||||
* 占比为衍生指标:各 IP 收入 ÷ 全部列入 IP 收入合计(源表占比列未填)。
|
||||
*
|
||||
@@ -29,18 +29,28 @@ const COLORS = [
|
||||
];
|
||||
|
||||
export function ppmtIpShareChart(rows, { width, height }) {
|
||||
const data = rows.map((row, i) => ({
|
||||
name: row.ip,
|
||||
value: row.revenue,
|
||||
itemStyle: { color: COLORS[i % COLORS.length] },
|
||||
}));
|
||||
const data = rows.map((row, i) => {
|
||||
const color = COLORS[i % COLORS.length];
|
||||
return {
|
||||
name: row.ip,
|
||||
value: row.revenue,
|
||||
itemStyle: { color },
|
||||
// 逐扇区覆盖标签 rich:IP 名与所在区块同色,其余样式沿用系列级配置
|
||||
label: {
|
||||
rich: {
|
||||
name: { fontFamily: FONT, fontSize: 12, fontWeight: 600, color, lineHeight: 16 },
|
||||
value: { fontFamily: FONT, fontSize: 11, color: PALETTE.grey, lineHeight: 15 },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
const total = data.reduce((s, d) => s + d.value, 0);
|
||||
const byName = Object.fromEntries(data.map((d) => [d.name, d]));
|
||||
|
||||
// 把「饼图 + 两侧外标签」当成一个整体,与图例并成一组居中摆放:
|
||||
// 图例紧跟在右侧标签外缘之后,不会被推到画布边缘留下大片空白
|
||||
const LEGEND_WIDTH = 152; // 最宽一行「17.7 亿元 · 10.3%」约 120px + 色块
|
||||
const LEGEND_GAP = 24; // 右侧标签外缘 → 图例
|
||||
const LEGEND_GAP = 56; // 右侧标签外缘 → 图例;最长数值行会侵入该间隙,实测 24px 太挤
|
||||
// 左右标签占位不对称:左侧文字向左延伸(text-anchor: end)要占满,
|
||||
// 右侧只需容下引导线 + 首行文字起段,留太多会在图例前空出一大块
|
||||
const LABEL_LEFT = 135;
|
||||
@@ -105,7 +115,7 @@ export function ppmtIpShareChart(rows, { width, height }) {
|
||||
data,
|
||||
label: {
|
||||
show: true,
|
||||
formatter: (p) => `{name|${p.name}}\n{value|${p.value.toFixed(1)} 亿元}`,
|
||||
formatter: (p) => `{name|${p.name}}\n{value|${p.value.toFixed(1)} 亿元 / ${p.percent.toFixed(1)}%}`,
|
||||
rich: {
|
||||
name: { fontFamily: FONT, fontSize: 12, color: PALETTE.ink, fontWeight: 600, lineHeight: 16 },
|
||||
value: { fontFamily: FONT, fontSize: 11, color: PALETTE.grey, lineHeight: 15 },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, categoryAxis, titleStyle,
|
||||
financialViewDisclosure, financialViewLabel, shortStandalonePeriod,
|
||||
standardLegend, barSeries, footnote, watermarks, LAYOUT,
|
||||
standardLegend, barSeries, fanTinyLabelOffsets, stackedSharePoint, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
/**
|
||||
@@ -40,8 +40,14 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) {
|
||||
const periods = Object.keys(rows[0]).filter((k) => k !== 'ip');
|
||||
const names = rows.map((r) => r.ip);
|
||||
const totals = periods.map((p) => rows.reduce((s, r) => s + r[p], 0));
|
||||
// 归一化:各 IP 占当期合计的比重,逐期加总恰为 100%
|
||||
const toPct = (r) => periods.map((p, i) => (r[p] / totals[i]) * 100);
|
||||
// 归一化:各 IP 占当期合计的比重,逐期加总恰为 100%。
|
||||
// 转成「期间 × 系列」的行结构,供 fanTinyLabelOffsets 逐柱计算矮段偏移。
|
||||
const pctRows = periods.map((p, i) => Object.fromEntries(
|
||||
rows.map((r) => [r.ip, (r[p] / totals[i]) * 100]),
|
||||
));
|
||||
// 绘图区像素高:画布高 − grid top 145 − grid bottom 70;轴固定 0–100%
|
||||
const pxPerUnit = (height - LAYOUT.gridTop - 70) / 100;
|
||||
const rowOffsets = fanTinyLabelOffsets(pctRows, names, pxPerUnit);
|
||||
|
||||
return {
|
||||
backgroundColor: '#fff',
|
||||
@@ -83,9 +89,14 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) {
|
||||
interval: 20,
|
||||
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
||||
},
|
||||
series: rows.map((r) => barSeries({
|
||||
// 每个非零占比都要展示:塞得进段内的段内白字,矮段外置到柱体右侧(深字白描边)
|
||||
series: rows.map((r, ci) => barSeries({
|
||||
name: r.ip,
|
||||
data: toPct(r),
|
||||
data: pctRows.map((pr, ri) => stackedSharePoint(pr[r.ip], {
|
||||
pxPerUnit,
|
||||
labelColor: '#fff',
|
||||
dy: rowOffsets[ri][ci] ?? 0,
|
||||
})),
|
||||
color: COLORS[r.ip] ?? PALETTE.grey,
|
||||
barWidth: 40,
|
||||
stack: 'share',
|
||||
@@ -96,8 +107,8 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) {
|
||||
fontSize: 11,
|
||||
fontFamily: FONT,
|
||||
fontWeight: 500,
|
||||
// 低于 4% 的段塞不进字,留空靠 tooltip 读数
|
||||
formatter: (p) => (p.value >= 4 ? `${p.value.toFixed(1)}%` : ''),
|
||||
overflow: 'none',
|
||||
formatter: (p) => `${p.value.toFixed(1)}%`,
|
||||
},
|
||||
emphasis: { focus: 'series' },
|
||||
})),
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import {
|
||||
PALETTE, FONT, titleStyle, footnote, watermarks, legendStyle,
|
||||
LAYOUT, } from '../theme.js';
|
||||
|
||||
/**
|
||||
* 泡泡玛特区域渠道分布图(2026H1)
|
||||
* 使用交错正负轴展示不同区域和渠道的收入
|
||||
*/
|
||||
export function regionChannelChart(rows, { width, height }) {
|
||||
// 数据结构重组:按区域分组
|
||||
const regions = ['中国', '海外', '亚太', '美洲', '欧洲及其他'];
|
||||
const channels = ['线下渠道', '线上渠道', '批发及其他'];
|
||||
|
||||
// 构建数据映射
|
||||
const dataMap = {};
|
||||
rows.forEach(row => {
|
||||
const key = `${row.region}_${row.channel}`;
|
||||
dataMap[key] = row.value;
|
||||
});
|
||||
|
||||
// 为每个渠道构建系列数据
|
||||
const seriesData = channels.map(channel => {
|
||||
return regions.map(region => {
|
||||
const key = `${region}_${channel}`;
|
||||
const value = dataMap[key] || 0;
|
||||
// 线上渠道显示为负值(左侧),其他为正值(右侧)
|
||||
return channel === '线上渠道' ? -value : value;
|
||||
});
|
||||
});
|
||||
|
||||
// 配色
|
||||
const channelColors = {
|
||||
'线下渠道': PALETTE.navy,
|
||||
'线上渠道': PALETTE.coral,
|
||||
'批发及其他': PALETTE.sage,
|
||||
};
|
||||
|
||||
return {
|
||||
backgroundColor: '#fff',
|
||||
title: {
|
||||
...titleStyle,
|
||||
text: '泡泡玛特区域渠道分布(2026H1)',
|
||||
subtext: '左侧:线上渠道 | 右侧:线下渠道、批发及其他,单位:亿元',
|
||||
top: 18,
|
||||
},
|
||||
legend: {
|
||||
...legendStyle,
|
||||
data: channels,
|
||||
top: LAYOUT.legendTop,
|
||||
itemGap: 32,
|
||||
textStyle: { fontFamily: FONT, fontSize: 13 },
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
textStyle: { fontFamily: FONT },
|
||||
formatter: (params) => {
|
||||
let result = `<b>${params[0].axisValue}</b><br/>`;
|
||||
params.forEach(p => {
|
||||
const value = Math.abs(p.value);
|
||||
result += `${p.marker}${p.seriesName}: ${value.toFixed(2)} 亿元<br/>`;
|
||||
});
|
||||
return result;
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 100,
|
||||
right: 100,
|
||||
top: 145,
|
||||
bottom: 90,
|
||||
containLabel: false,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
position: 'top',
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: { color: '#e8e8e8', width: 1 }
|
||||
},
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
formatter: (value) => Math.abs(value),
|
||||
fontFamily: FONT,
|
||||
fontSize: 12,
|
||||
color: '#8c8c8c',
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: regions,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
fontFamily: FONT,
|
||||
fontSize: 13,
|
||||
color: '#333',
|
||||
fontWeight: '500',
|
||||
},
|
||||
inverse: false,
|
||||
},
|
||||
series: channels.map((channel, idx) => ({
|
||||
name: channel,
|
||||
type: 'bar',
|
||||
stack: channel === '线上渠道' ? 'left' : 'right',
|
||||
data: seriesData[idx],
|
||||
itemStyle: { color: channelColors[channel] },
|
||||
label: {
|
||||
show: true,
|
||||
position: channel === '线上渠道' ? 'left' : 'right',
|
||||
formatter: (params) => {
|
||||
const value = Math.abs(params.value);
|
||||
return value > 0 ? value.toFixed(1) : '';
|
||||
},
|
||||
fontFamily: FONT,
|
||||
fontSize: 11,
|
||||
color: '#333',
|
||||
},
|
||||
barWidth: 22,
|
||||
})),
|
||||
graphic: [
|
||||
...watermarks(width, height),
|
||||
footnote('数据来源:泡泡玛特财报 | @Joey知行记'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
// 同比虚线用柱色的同色系深色变体:压在柱体和彼此之间都能区分,色相仍与柱对应
|
||||
const REGIONS = [
|
||||
{ key: 'china', name: '中国(含港澳台)', color: '#3F7FBE' },
|
||||
{ key: 'apac', name: '亚太', color: '#36A28F' },
|
||||
{ key: 'americas', name: '美洲', color: '#E4A349' },
|
||||
{ key: 'europe_other', name: '欧洲及其他', color: PALETTE.brown },
|
||||
{ key: 'china', name: '中国(含港澳台)', color: '#3F7FBE', lineColor: PALETTE.navy },
|
||||
// 亚太青、美洲金、深棕在色板无对应深色,按柱色加深或取邻近深色
|
||||
{ key: 'apac', name: '亚太', color: '#36A28F', lineColor: '#1F6E5F' },
|
||||
{ key: 'americas', name: '美洲', color: '#E4A349', lineColor: PALETTE.orange },
|
||||
{ key: 'europe_other', name: '欧洲及其他', color: PALETTE.brown, lineColor: '#4A3117' },
|
||||
];
|
||||
|
||||
function yoyByPeriod(rows, key) {
|
||||
@@ -37,7 +39,7 @@ export function retailStoreRevenueChart(rows, { width, height, view = 'all' }) {
|
||||
title: {
|
||||
...titleStyle,
|
||||
text: `泡泡玛特各地区零售店单店收入(${financialViewLabel(view)})`,
|
||||
subtext: '柱状为单店收入;同色虚线为单店收入同比;H2为下半年单期',
|
||||
subtext: '柱状为单店收入;深色虚线为对应柱的单店收入同比;H2为下半年单期',
|
||||
},
|
||||
legend: standardLegend(legendItems, { itemGap: 18 }),
|
||||
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
|
||||
@@ -93,12 +95,12 @@ export function retailStoreRevenueChart(rows, { width, height, view = 'all' }) {
|
||||
...lineSeries({
|
||||
name: `${region.name}同比`,
|
||||
data: region.data,
|
||||
color: region.color,
|
||||
color: region.lineColor,
|
||||
width: 2,
|
||||
formatter: (point) => `${point.value.toFixed(0)}%`,
|
||||
}),
|
||||
yAxisIndex: 1,
|
||||
lineStyle: { color: region.color, width: 2, type: 'dashed' },
|
||||
lineStyle: { color: region.lineColor, width: 2, type: 'dashed' },
|
||||
z: 10,
|
||||
})),
|
||||
],
|
||||
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
// 同比虚线用柱色的同色系深色变体:压在柱体和彼此之间都能区分,色相仍与柱对应
|
||||
const REGIONS = [
|
||||
{ key: 'china', name: '中国(含港澳台)', color: '#71AD81' },
|
||||
{ key: 'overseas', name: '海外', color: PALETTE.brown },
|
||||
{ key: 'china', name: '中国(含港澳台)', color: '#71AD81', lineColor: PALETTE.green },
|
||||
// 色板没有深棕,按 brown 加深
|
||||
{ key: 'overseas', name: '海外', color: PALETTE.brown, lineColor: '#4A3117' },
|
||||
];
|
||||
|
||||
function yoyByPeriod(rows, key) {
|
||||
@@ -35,7 +37,7 @@ export function robotStoreRevenueChart(rows, { width, height, view = 'all' }) {
|
||||
title: {
|
||||
...titleStyle,
|
||||
text: `泡泡玛特机器人商店单店收入(${financialViewLabel(view)})`,
|
||||
subtext: '柱状为单店收入;同色虚线为单店收入同比;H2为下半年单期',
|
||||
subtext: '柱状为单店收入;深色虚线为对应柱的单店收入同比;H2为下半年单期',
|
||||
},
|
||||
legend: standardLegend(legendItems, { itemGap: 18 }),
|
||||
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
|
||||
@@ -91,12 +93,12 @@ export function robotStoreRevenueChart(rows, { width, height, view = 'all' }) {
|
||||
...lineSeries({
|
||||
name: `${region.name}同比`,
|
||||
data: region.data,
|
||||
color: region.color,
|
||||
color: region.lineColor,
|
||||
width: 2,
|
||||
formatter: (point) => `${point.value.toFixed(0)}%`,
|
||||
}),
|
||||
yAxisIndex: 1,
|
||||
lineStyle: { color: region.color, width: 2, type: 'dashed' },
|
||||
lineStyle: { color: region.lineColor, width: 2, type: 'dashed' },
|
||||
z: 10,
|
||||
})),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user