141 lines
5.2 KiB
JavaScript
141 lines
5.2 KiB
JavaScript
import { col } from '../csv.js';
|
||
import {
|
||
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
|
||
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: {
|
||
...titleStyle,
|
||
text: `泡泡玛特费用结构与费用率(${financialViewLabel(view)})`,
|
||
subtext: ['销售、管理、财务费用;折线为四项费用率', financialViewDisclosure(view)].filter(Boolean).join('\n'),
|
||
},
|
||
legend: {
|
||
...legendStyle,
|
||
top: LAYOUT.legendTop,
|
||
data: ['销售费用', '管理费用', '财务费用(净额)', '四项费用率'],
|
||
},
|
||
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: 52,
|
||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||
min: axisMin,
|
||
max: axisMax,
|
||
interval: 10,
|
||
},
|
||
{
|
||
...axisCommon,
|
||
type: 'value',
|
||
name: '四项费用率(%)',
|
||
nameLocation: 'middle',
|
||
nameGap: 58,
|
||
nameRotate: -90,
|
||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||
min: 0,
|
||
max: 50,
|
||
interval: 10,
|
||
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
||
splitLine: { show: false },
|
||
},
|
||
],
|
||
series: [
|
||
...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: '四项费用率',
|
||
data: col(rows, 'four_expense_rate'),
|
||
color: PALETTE.red,
|
||
formatter: (p) => `${p.value.toFixed(1)}%`,
|
||
width: 3,
|
||
}),
|
||
yAxisIndex: 1,
|
||
z: 10,
|
||
},
|
||
],
|
||
graphic: [
|
||
...watermarks(width, height),
|
||
footnote('数据来源:泡泡玛特合并利润表;H2为单独半年 | @Joey知行记'),
|
||
],
|
||
};
|
||
}
|