feat(ppmt): 新增分地区收入占比与门店数量图表

- 新增分地区收入占比、零售门店数量、机器人商店数量三张图表及对应 CSV 数据
- 渠道收入图矮段标签外置并扇形排布,避免段内标签挤压
- IP 收入占比图改用 standardLegend 统一图例样式
- 折线标注增加白底描边,提升可读性
This commit is contained in:
R524809
2026-08-31 16:31:44 +08:00
parent d51890a450
commit 2555453b79
17 changed files with 573 additions and 35 deletions
+93 -9
View File
@@ -6,20 +6,77 @@ import {
} from '../theme.js';
/** 泡泡玛特渠道收入结构与增速。 */
const channels = [
{ key: 'retail', name: '零售店', color: CHANNEL_COLORS.retail },
{ key: 'online', name: '线上', color: CHANNEL_COLORS.online },
{ key: 'robot_store', name: '机器人商店', color: CHANNEL_COLORS.robotStore },
{ key: 'wholesale', name: '批发', color: CHANNEL_COLORS.wholesale },
];
// 段内标签文字色:浅色底(批发金)用深字,其余深色底用白字
const INSIDE_LABEL_COLORS = {
retail: '#fff',
online: '#fff',
robot_store: '#fff',
wholesale: PALETTE.ink,
};
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 channels = [
{ key: 'retail', name: '零售店', color: CHANNEL_COLORS.retail },
{ key: 'online', name: '线上', color: CHANNEL_COLORS.online },
{ key: 'robot_store', name: '机器人商店', color: CHANNEL_COLORS.robotStore },
{ key: 'wholesale', name: '批发', color: CHANNEL_COLORS.wholesale },
];
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;
// 绘图区像素高:画布高 − grid top 145 − grid bottom 90
const pxPerUnit = (height - LAYOUT.gridTop - 90) / axisMax;
// 每根柱里放不下的矮段:标签外置到柱体右侧。同一柱内多个矮段标签
// 自下而上保持 12px 间距;整根柱都是矮段时,纵向扇形以柱身中部为中心,
// 避免标签整体飘离柱顶。返回 { 渠道序号: offset dy }(dy 为像素,正方向向下)。
const fanOutsideLabels = (row) => {
const values = channels.map((item) => num(row[item.key]));
const centers = [];
let cum = 0;
values.forEach((value) => {
centers.push((cum + value / 2) * pxPerUnit);
cum += value;
});
const barTopPx = cum * pxPerUnit;
const tinyIdx = channels
.map((_, i) => i)
.filter((i) => values[i] !== null && values[i] * pxPerUnit < MIN_INSIDE_PX);
if (tinyIdx.length === 0) return {};
const ys = tinyIdx.map((i) => centers[i]);
for (let k = 1; k < ys.length; k += 1) {
ys[k] = Math.max(ys[k], ys[k - 1] + 12);
}
if (tinyIdx.length === channels.length) {
const shift = barTopPx / 2 - (ys[0] + ys[ys.length - 1]) / 2;
for (let k = 0; k < ys.length; k += 1) ys[k] += shift;
if (ys[0] < 2) {
const lift = 2 - ys[0];
for (let k = 0; k < ys.length; k += 1) ys[k] += lift;
}
}
const offsets = {};
tinyIdx.forEach((i, k) => {
offsets[i] = centers[i] - ys[k];
});
return offsets;
};
const rowOffsets = rows.map(fanOutsideLabels);
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: `泡泡玛特渠道收入结构与增速(${financialViewLabel(view)})`,
subtext: ['渠道收入为堆叠柱;折线为营业收入同比增速', financialViewDisclosure(view)].filter(Boolean).join('\n'),
subtext: ['渠道收入为堆叠柱,柱内及柱侧标注各渠道数值;折线为营业收入同比增速', financialViewDisclosure(view)].filter(Boolean).join('\n'),
},
legend: {
...legendStyle,
@@ -43,6 +100,8 @@ export function channelRevenueChart(rows, { width, height, view = 'all' }) {
nameGap: 56,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
max: axisMax,
interval: axisMax % 4 === 0 ? axisMax / 4 : axisMax / 5,
},
{
...axisCommon,
@@ -60,12 +119,37 @@ export function channelRevenueChart(rows, { width, height, view = 'all' }) {
},
],
series: [
...channels.map((item) => ({
...channels.map((item, ci) => ({
name: item.name,
type: 'bar',
stack: 'channel-revenue',
data: col(rows, item.key),
data: rows.map((row, ri) => {
const value = num(row[item.key]);
if (value === null) return null;
const inside = value * pxPerUnit >= MIN_INSIDE_PX;
return {
value,
label: inside
? { position: 'inside', color: INSIDE_LABEL_COLORS[item.key] }
: {
position: 'right',
color: PALETTE.ink,
distance: 3,
offset: [0, rowOffsets[ri][ci] || 0],
textBorderColor: '#fff',
textBorderWidth: 2,
},
};
}),
itemStyle: { color: item.color },
label: {
show: true,
fontSize: 10,
fontWeight: 500,
fontFamily: FONT,
formatter: (p) => Number(p.value).toFixed(1),
overflow: 'none',
},
emphasis: { focus: 'series' },
})),
{