Files
smart-fin-chart/js/charts/ppmt_channel_revenue.js
T

177 lines
6.3 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;
const yoyValues = col(rows, 'revenue_yoy').filter(Number.isFinite);
// 全部/半年视图存在 2022H2 的负增长,右轴需包含负区间,避免折线被 0 轴裁切。
const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 50) * 50);
const yoyMax = Math.max(50, Math.ceil(Math.max(...yoyValues) / 50) * 50);
// 绘图区像素高:画布高 − 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: yoyMin,
max: yoyMax,
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'),
],
};
}