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