Files
smart-fin-chart/js/charts/ppmt_channel_revenue.js
T
R524809 2555453b79 feat(ppmt): 新增分地区收入占比与门店数量图表
- 新增分地区收入占比、零售门店数量、机器人商店数量三张图表及对应 CSV 数据
- 渠道收入图矮段标签外置并扇形排布,避免段内标签挤压
- IP 收入占比图改用 standardLegend 统一图例样式
- 折线标注增加白底描边,提升可读性
2026-08-31 16:31:44 +08:00

173 lines
6.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { col } from '../csv.js';
import {
CHANNEL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
financialViewDisclosure, financialViewLabel,
lineSeries, footnote, watermarks, LAYOUT,
} 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 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'),
},
legend: {
...legendStyle,
top: LAYOUT.legendTop,
data: [...channels.map((item) => item.name), '营业收入同比'],
},
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT },
valueFormatter: (value) => value.toFixed(1),
},
xAxis: { ...standalonePeriodAxis, data: col(rows, 'period') },
yAxis: [
{
...axisCommon,
type: 'value',
name: '渠道收入(亿元)',
nameLocation: 'middle',
nameGap: 56,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
max: axisMax,
interval: axisMax % 4 === 0 ? axisMax / 4 : axisMax / 5,
},
{
...axisCommon,
type: 'value',
name: '营业收入同比(%)',
nameLocation: 'middle',
nameGap: 60,
nameRotate: -90,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: 0,
max: 250,
interval: 50,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
},
],
series: [
...channels.map((item, ci) => ({
name: item.name,
type: 'bar',
stack: 'channel-revenue',
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' },
})),
{
...lineSeries({
name: '营业收入同比',
data: col(rows, 'revenue_yoy'),
color: CHANNEL_COLORS.revenueGrowth,
formatter: (p) => `${p.value.toFixed(0)}%`,
width: 3,
}),
yAxisIndex: 1,
z: 10,
},
],
graphic: [
...watermarks(width, height),
footnote('数据来源:泡泡玛特营收构成表;渠道口径按财报披露 | @思考的Joey'),
],
};
}