diff --git a/data/ppmt/ppmt_region_channel_2026h1.csv b/data/ppmt/ppmt_region_channel_2026h1.csv
deleted file mode 100644
index ecf5b76..0000000
--- a/data/ppmt/ppmt_region_channel_2026h1.csv
+++ /dev/null
@@ -1,16 +0,0 @@
-region,channel,value
-中国,线下渠道,68.69
-中国,线上渠道,47.79
-中国,批发及其他,5.53
-海外,线下渠道,31.94
-海外,线上渠道,14.34
-海外,批发及其他,3.45
-亚太,线下渠道,17.73
-亚太,线上渠道,6.44
-亚太,批发及其他,1.59
-美洲,线下渠道,10.07
-美洲,线上渠道,7.22
-美洲,批发及其他,1.62
-欧洲及其他,线下渠道,4.14
-欧洲及其他,线上渠道,0.68
-欧洲及其他,批发及其他,0.24
diff --git a/js/charts/index.js b/js/charts/index.js
index 1d96c65..91c60e0 100644
--- a/js/charts/index.js
+++ b/js/charts/index.js
@@ -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,
diff --git a/js/charts/ppmt_assets.js b/js/charts/ppmt_assets.js
index eb58c87..b114a04 100644
--- a/js/charts/ppmt_assets.js
+++ b/js/charts/ppmt_assets.js
@@ -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: '资产总计',
diff --git a/js/charts/ppmt_category.js b/js/charts/ppmt_category.js
index a782a69..3846f7a 100644
--- a/js/charts/ppmt_category.js
+++ b/js/charts/ppmt_category.js
@@ -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 = `${params[0].axisValue}
`;
- let total = 0;
- params.forEach(p => {
- total += p.value;
- result += `${p.marker}${p.seriesName}: ${p.value.toFixed(2)} 亿元
`;
+ bars.forEach((p) => { result += `${p.marker}${p.seriesName}: ${p.value.toFixed(2)} 亿元
`; });
+ result += `合计: ${total.toFixed(2)} 亿元
`;
+ lines.forEach((p) => {
+ result += `${p.marker}${p.seriesName}: ${p.value > 0 ? '+' : ''}${p.value.toFixed(1)}%
`;
});
- result += `合计: ${total.toFixed(2)} 亿元`;
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知行记'),
],
};
}
diff --git a/js/charts/ppmt_channel_revenue.js b/js/charts/ppmt_channel_revenue.js
index e899c18..fa22015 100644
--- a/js/charts/ppmt_channel_revenue.js
+++ b/js/charts/ppmt_channel_revenue.js
@@ -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);
diff --git a/js/charts/ppmt_expense_structure.js b/js/charts/ppmt_expense_structure.js
index a7dc3f8..944ca2b 100644
--- a/js/charts/ppmt_expense_structure.js
+++ b/js/charts/ppmt_expense_structure.js
@@ -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: '四项费用率',
diff --git a/js/charts/ppmt_ip.js b/js/charts/ppmt_ip.js
index f7cfd16..cec6233 100644
--- a/js/charts/ppmt_ip.js
+++ b/js/charts/ppmt_ip.js
@@ -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: [
diff --git a/js/charts/ppmt_ip_share.js b/js/charts/ppmt_ip_share.js
index ead3f78..aa4915b 100644
--- a/js/charts/ppmt_ip_share.js
+++ b/js/charts/ppmt_ip_share.js
@@ -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 },
diff --git a/js/charts/ppmt_ip_share_history.js b/js/charts/ppmt_ip_share_history.js
index feed01a..3ea2bfa 100644
--- a/js/charts/ppmt_ip_share_history.js
+++ b/js/charts/ppmt_ip_share_history.js
@@ -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' },
})),
diff --git a/js/charts/ppmt_region_channel.js b/js/charts/ppmt_region_channel.js
deleted file mode 100644
index d74a9a2..0000000
--- a/js/charts/ppmt_region_channel.js
+++ /dev/null
@@ -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 = `${params[0].axisValue}
`;
- params.forEach(p => {
- const value = Math.abs(p.value);
- result += `${p.marker}${p.seriesName}: ${value.toFixed(2)} 亿元
`;
- });
- 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知行记'),
- ],
- };
-}
diff --git a/js/charts/ppmt_retail_store_revenue.js b/js/charts/ppmt_retail_store_revenue.js
index c37c997..36ffe2d 100644
--- a/js/charts/ppmt_retail_store_revenue.js
+++ b/js/charts/ppmt_retail_store_revenue.js
@@ -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,
})),
],
diff --git a/js/charts/ppmt_robot_store_revenue.js b/js/charts/ppmt_robot_store_revenue.js
index 89ea8be..472d659 100644
--- a/js/charts/ppmt_robot_store_revenue.js
+++ b/js/charts/ppmt_robot_store_revenue.js
@@ -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,
})),
],
diff --git a/js/theme.js b/js/theme.js
index ce2c82f..92f0257 100644
--- a/js/theme.js
+++ b/js/theme.js
@@ -161,6 +161,15 @@ export const LAYOUT = {
gridTop: 145, // 绘图区顶部边距(图例到绘图区约 60px)
};
+/** 数值轴整齐上限候选:轴 max 从中取第一个能容纳数据的刻度 */
+export const NICE_AXIS_MAX = [20, 25, 50, 60, 80, 100, 120, 150, 200, 250, 300, 400, 500, 600, 800, 1000];
+
+/** 按数据最大值取整齐轴上限;超出候选表尾时按 100 的整数倍向上取整并留 20% 余量 */
+export function niceAxisMax(dataMax) {
+ return NICE_AXIS_MAX.find((max) => max >= dataMax * 1.02)
+ ?? Math.ceil((dataMax * 1.2) / 100) * 100;
+}
+
// 统一图例图标宽度,同时保留 ECharts 按系列类型生成的形状。
export const LEGEND_ITEM_WIDTH = 20;
diff --git a/manifest.json b/manifest.json
index 30f8efe..9611bfe 100644
--- a/manifest.json
+++ b/manifest.json
@@ -18,7 +18,6 @@
"ppmt-ip-share-history": { "key": "ppmt-ip-share-history", "title": "各 IP 半年收入占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_ip_share_history.csv", "annual": "data/ppmt/ppmt_ip_share_history_annual.csv", "halfyear": "data/ppmt/ppmt_ip_share_history_halfyear.csv" }, "height": 700, "filename": "泡泡玛特各IP半年收入占比" },
"ppmt-ip-ex-monsters": { "key": "ppmt-ip-ex-monsters", "title": "除 THE MONSTERS 外的 IP 收入及同比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_ip_ex_monsters.csv", "annual": "data/ppmt/ppmt_ip_ex_monsters_annual.csv", "halfyear": "data/ppmt/ppmt_ip_ex_monsters_halfyear.csv" }, "height": 700, "filename": "泡泡玛特除MONSTERS外IP收入及同比" },
"ppmt-category": { "key": "ppmt-category", "title": "品类收入结构", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_category.csv", "annual": "data/ppmt/ppmt_category_annual.csv", "halfyear": "data/ppmt/ppmt_category_halfyear.csv" }, "height": 680, "filename": "泡泡玛特品类结构" },
- "ppmt-region-channel": { "key": "ppmt-region-channel", "title": "区域渠道分布", "csv": "data/ppmt/ppmt_region_channel_2026h1.csv", "height": 680, "filename": "泡泡玛特区域渠道分布2026H1" },
"ppmt-region-yoy": { "key": "ppmt-region-yoy", "title": "区域渠道同比增速", "csv": "data/ppmt/ppmt_region_channel_yoy.csv", "height": 620, "filename": "泡泡玛特区域渠道同比增速" },
"ppmt-region-yoy-timeseries": { "key": "ppmt-region-yoy-timeseries", "title": "区域渠道同比增速趋势", "csv": "data/ppmt/ppmt_region_channel_yoy_timeseries.csv", "height": 760, "filename": "泡泡玛特区域渠道同比增速趋势" },
"ppmt-cash-conversion": { "key": "ppmt-cash-conversion", "title": "经营现金流与净利润", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_cash_conversion.csv", "annual": "data/ppmt/ppmt_cash_conversion_annual.csv", "halfyear": "data/ppmt/ppmt_cash_conversion_halfyear.csv" }, "height": 700, "filename": "泡泡玛特经营现金流与净利润" },
@@ -44,12 +43,12 @@
"ppmt-region-share": { "key": "ppmt-region-share", "title": "分地区营收占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_region_share.csv", "annual": "data/ppmt/ppmt_region_share_annual.csv", "halfyear": "data/ppmt/ppmt_region_share_halfyear.csv" }, "height": 680, "filename": "泡泡玛特分地区营收占比" }
},
"tabs": [
- { "id": "overview", "name": "概览", "chartRefs": ["ppmt-growth", "ppmt-profit", "ppmt-ip-share", "ppmt-region-channel"] },
+ { "id": "overview", "name": "概览", "chartRefs": ["ppmt-growth", "ppmt-profit", "ppmt-ip-share"] },
{ "id": "profitability", "name": "盈利能力", "chartRefs": ["ppmt-growth", "ppmt-core-profit", "ppmt-core-margin", "ppmt-bridge", "ppmt-sankey", "ppmt-cash-conversion", "ppmt-cashflow-composition"] },
{ "id": "expense", "name": "费用率", "chartRefs": ["ppmt-margins", "ppmt-expense-structure"] },
{ "id": "balance-sheet", "name": "资产负债", "chartRefs": ["ppmt-assets", "ppmt-inventory", "ppmt-liquidity-working-capital", "ppmt-working-capital-days"] },
{ "id": "ip-category", "name": "IP 与品类", "chartRefs": ["ppmt-ip", "ppmt-ip-share", "ppmt-ip-share-history", "ppmt-ip-ex-monsters", "ppmt-category"] },
- { "id": "region-channel", "name": "区域渠道", "chartRefs": ["ppmt-region-channel", "ppmt-region-yoy", "ppmt-region-yoy-timeseries", "ppmt-channel-revenue", "ppmt-channel-share", "ppmt-channel-share-china", "ppmt-channel-amount-china", "ppmt-channel-share-apac", "ppmt-channel-amount-apac", "ppmt-channel-share-americas", "ppmt-channel-amount-americas", "ppmt-channel-share-europe", "ppmt-channel-amount-europe", "ppmt-stores-retail", "ppmt-stores-retail-share", "ppmt-stores-robot", "ppmt-region-share"] },
+ { "id": "region-channel", "name": "区域渠道", "chartRefs": ["ppmt-region-yoy", "ppmt-region-yoy-timeseries", "ppmt-channel-revenue", "ppmt-channel-share", "ppmt-channel-share-china", "ppmt-channel-amount-china", "ppmt-channel-share-apac", "ppmt-channel-amount-apac", "ppmt-channel-share-americas", "ppmt-channel-amount-americas", "ppmt-channel-share-europe", "ppmt-channel-amount-europe", "ppmt-stores-retail", "ppmt-stores-retail-share", "ppmt-stores-robot", "ppmt-region-share"] },
{ "id": "store-revenue", "name": "单店收入", "chartRefs": ["ppmt-retail-store-revenue", "ppmt-robot-store-revenue"] }
]
},