Files
smart-fin-chart/js/charts/ppmt_expense_structure.js
T
2026-09-10 17:44:09 +08:00

141 lines
5.2 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 {
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知行记'),
],
};
}