feat: 优化泡泡玛特的图表

This commit is contained in:
R524809
2026-09-10 17:44:09 +08:00
parent a6387f8220
commit 417116b928
14 changed files with 327 additions and 299 deletions
+69 -4
View File
@@ -1,12 +1,49 @@
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle,
financialViewDisclosure, financialViewLabel,
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: {
@@ -35,6 +72,9 @@ export function expenseStructureChart(rows, { width, height, view = 'all' }) {
nameLocation: 'middle',
nameGap: 52,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
min: axisMin,
max: axisMax,
interval: 10,
},
{
...axisCommon,
@@ -52,9 +92,34 @@ export function expenseStructureChart(rows, { width, height, view = 'all' }) {
},
],
series: [
{ name: '销售费用', type: 'bar', stack: 'expenses', data: col(rows, 'sales_expense'), itemStyle: { color: PALETTE.orange } },
{ name: '管理费用', type: 'bar', stack: 'expenses', data: col(rows, 'admin_expense'), itemStyle: { color: PALETTE.purple } },
{ name: '财务费用(净额)', type: 'bar', stack: 'expenses', data: col(rows, 'financial_expense'), itemStyle: { color: PALETTE.blue } },
...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: '四项费用率',