diff --git a/data/ppmt/ppmt_region_channel_2026h1.csv b/data/ppmt/ppmt_region_channel_2026h1.csv deleted file mode 100644 index ecf5b76..0000000 --- a/data/ppmt/ppmt_region_channel_2026h1.csv +++ /dev/null @@ -1,16 +0,0 @@ -region,channel,value -中国,线下渠道,68.69 -中国,线上渠道,47.79 -中国,批发及其他,5.53 -海外,线下渠道,31.94 -海外,线上渠道,14.34 -海外,批发及其他,3.45 -亚太,线下渠道,17.73 -亚太,线上渠道,6.44 -亚太,批发及其他,1.59 -美洲,线下渠道,10.07 -美洲,线上渠道,7.22 -美洲,批发及其他,1.62 -欧洲及其他,线下渠道,4.14 -欧洲及其他,线上渠道,0.68 -欧洲及其他,批发及其他,0.24 diff --git a/js/charts/index.js b/js/charts/index.js index 1d96c65..91c60e0 100644 --- a/js/charts/index.js +++ b/js/charts/index.js @@ -8,7 +8,6 @@ import { profitChart } from './ppmt_profit.js'; import { ipChart } from './ppmt_ip.js'; import { categoryChart } from './ppmt_category.js'; import { sankeyChart } from './ppmt_sankey.js'; -import { regionChannelChart } from './ppmt_region_channel.js'; import { regionChannelYoyChart } from './ppmt_region_yoy.js'; import { regionChannelYoyTimeseriesChart } from './ppmt_region_channel_yoy_timeseries.js'; import { ppmtGrowthChart } from './ppmt_growth.js'; @@ -60,7 +59,6 @@ export const chartRegistry = { 'ppmt-ip': ipChart, 'ppmt-category': categoryChart, 'ppmt-sankey': sankeyChart, - 'ppmt-region-channel': regionChannelChart, 'ppmt-region-yoy': regionChannelYoyChart, 'ppmt-region-yoy-timeseries': regionChannelYoyTimeseriesChart, 'ppmt-growth': ppmtGrowthChart, diff --git a/js/charts/ppmt_assets.js b/js/charts/ppmt_assets.js index eb58c87..b114a04 100644 --- a/js/charts/ppmt_assets.js +++ b/js/charts/ppmt_assets.js @@ -1,16 +1,45 @@ import { col } from '../csv.js'; import { PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle, - financialViewLabel, + financialViewLabel, niceAxisMax, fanTinyLabelOffsets, stackedSharePoint, lineSeries, footnote, watermarks, LAYOUT, } from '../theme.js'; +const KEYS = ['cash_like', 'receivables', 'investment', 'operating_assets']; +const NAMES = { + cash_like: '类现金', + receivables: '应收账款', + investment: '投资资产', + operating_assets: '经营类资产', +}; +const COLORS = { + cash_like: PALETTE.blue, + receivables: PALETTE.orange, + investment: PALETTE.purple, + operating_assets: PALETTE.sage, +}; +// 段内标签文字色:浅色段(经营类资产灰绿)用深字,深色段用白字 +const INSIDE_LABEL_COLORS = { + cash_like: '#fff', + receivables: '#fff', + investment: '#fff', + operating_assets: PALETTE.ink, +}; + /** * 泡泡玛特资产结构演变图 - * 堆叠柱状图展示资产配置,折线图展示总资产增长 + * 堆叠柱状图展示资产配置(每段标数值),折线图展示总资产增长 */ export function assetsChart(rows, { width, height, view = 'all' }) { const periods = col(rows, 'period'); + const totalMax = Math.max(...rows.map((r) => r.total_assets)); + // 轴上限随数据走(2025H2 总资产已 321 亿),矮段像素判定才有确定基准 + const axisMax = niceAxisMax(totalMax); + const interval = axisMax % 7 === 0 ? axisMax / 7 : axisMax / 5; + // 绘图区像素高:画布高 − grid top 145 − grid bottom 90 + const pxPerUnit = (height - LAYOUT.gridTop - 90) / axisMax; + // 早期年份应收/投资不足 1 亿,矮段标签外置到柱体右侧扇形排开 + const rowOffsets = fanTinyLabelOffsets(rows, KEYS, pxPerUnit); return { backgroundColor: '#fff', @@ -22,7 +51,7 @@ export function assetsChart(rows, { width, height, view = 'all' }) { legend: { ...legendStyle, top: LAYOUT.legendTop, - data: ['类现金', '应收账款', '投资资产', '经营类资产', '资产总计'], + data: [...KEYS.map((key) => NAMES[key]), '资产总计'], }, grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 }, tooltip: { @@ -40,8 +69,8 @@ export function assetsChart(rows, { width, height, view = 'all' }) { nameGap: 52, nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, min: 0, - max: 350, - interval: 50, + max: axisMax, + interval, }, { ...axisCommon, @@ -52,44 +81,34 @@ export function assetsChart(rows, { width, height, view = 'all' }) { nameRotate: -90, nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, min: 0, - max: 350, - interval: 50, + max: axisMax, + interval, splitLine: { show: false }, }, ], series: [ - { - name: '类现金', + ...KEYS.map((key, ci) => ({ + name: NAMES[key], type: 'bar', stack: 'assets', - data: col(rows, 'cash_like'), - itemStyle: { color: PALETTE.blue }, + // 每段都标数值:矮段塞不进字的,外置到柱体右侧(机制同渠道金额图) + data: rows.map((row, ri) => stackedSharePoint(row[key], { + pxPerUnit, + labelColor: INSIDE_LABEL_COLORS[key], + dy: rowOffsets[ri][ci] ?? 0, + })), + itemStyle: { color: COLORS[key] }, emphasis: { focus: 'series' }, - }, - { - name: '应收账款', - type: 'bar', - stack: 'assets', - data: col(rows, 'receivables'), - itemStyle: { color: PALETTE.orange }, - emphasis: { focus: 'series' }, - }, - { - name: '投资资产', - type: 'bar', - stack: 'assets', - data: col(rows, 'investment'), - itemStyle: { color: PALETTE.purple }, - emphasis: { focus: 'series' }, - }, - { - name: '经营类资产', - type: 'bar', - stack: 'assets', - data: col(rows, 'operating_assets'), - itemStyle: { color: PALETTE.sage }, - emphasis: { focus: 'series' }, - }, + label: { + show: true, + position: 'inside', + color: INSIDE_LABEL_COLORS[key], + fontSize: 11, + fontFamily: FONT, + overflow: 'none', + formatter: (p) => `${p.value.toFixed(1)}`, + }, + })), { ...lineSeries({ name: '资产总计', diff --git a/js/charts/ppmt_category.js b/js/charts/ppmt_category.js index a782a69..3846f7a 100644 --- a/js/charts/ppmt_category.js +++ b/js/charts/ppmt_category.js @@ -1,66 +1,150 @@ import { col } from '../csv.js'; import { PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle, - financialViewDisclosure, financialViewLabel, - footnote, watermarks, LAYOUT, + financialViewDisclosure, financialViewLabel, niceAxisMax, + fanTinyLabelOffsets, stackedSharePoint, + lineSeries, footnote, watermarks, LAYOUT, } from '../theme.js'; /** * 泡泡玛特品类收入结构图 - * 堆叠柱状图展示各品类的收入占比和增长 + * 堆叠柱展示各品类收入;折线(右轴)展示各品类收入同比,折线与柱同色便于对应。 + * 同比为衍生指标:当期 ÷ 上年同期 − 1(H2 对 H2 单半年、FY 对 FY 累计,同期对同期), + * 2024 年各期无上年基数,不计算。 */ +// 同比折线用柱色的同色系深色变体:压在柱体上能区分开,又靠色相保持与柱的对应 +const CATEGORIES = [ + { name: '毛绒', key: 'plush', color: PALETTE.coral, lineColor: PALETTE.red }, + { name: '手办', key: 'figure', color: PALETTE.blue, lineColor: PALETTE.navy }, + // 色板没有深紫,按 MEGA 紫加深 + { name: 'MEGA', key: 'mega', color: PALETTE.purple, lineColor: '#5E2A79' }, + { name: '衍生品及其他', key: 'other', color: PALETTE.sage, lineColor: PALETTE.green }, +]; + export function categoryChart(rows, { width, height, view = 'all' }) { // 过滤掉表头行("品类"那一行) const dataRows = rows.filter(r => r.period && r.period !== '品类'); const periods = col(dataRows, 'period'); + const num = (r, k) => (Number.isFinite(r[k]) ? r[k] : null); + + // 上年同期的期间下标(H1↔H1、H2↔H2、FY↔FY),无则 -1 + const priorIndex = periods.map((p) => { + if (!/^20\d{2}(H1|H2|FY)$/.test(p)) return -1; + const prev = p.replace(/^20(\d{2})/, (_, yy) => `20${String(Number(yy) - 1).padStart(2, '0')}`); + return periods.indexOf(prev); + }); + const yoyByCat = Object.fromEntries(CATEGORIES.map((c) => [ + c.key, + periods.map((_, i) => { + const j = priorIndex[i]; + const cur = num(dataRows[i], c.key); + const prev = j >= 0 ? num(dataRows[j], c.key) : null; + if (cur == null || prev == null || prev === 0) return null; + return (cur / prev - 1) * 100; + }), + ])); + const yoyValues = CATEGORIES.flatMap((c) => yoyByCat[c.key]).filter(Number.isFinite); + const yoyMax = Math.max(100, Math.ceil(Math.max(...yoyValues) / 100) * 100); + const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 100) * 100); + // 右轴固定整百刻度:范围 ≤500 时 100% 一步,否则 200% 一步,刻度数控制在 7 以内 + const yoyInterval = yoyMax - yoyMin <= 500 ? 100 : 200; + + // 多折线标签避让(同 ppmt_margins labeledData):比较上下间隙,朝更大的一侧放 + const yoyLabelPos = periods.map((_, i) => { + const ranked = CATEGORIES + .map((c) => yoyByCat[c.key][i]) + .filter(Number.isFinite) + .sort((a, b) => b - a); + return Object.fromEntries(CATEGORIES.map((c) => { + const v = yoyByCat[c.key][i]; + if (!Number.isFinite(v)) return [c.key, 'top']; + const rank = ranked.indexOf(v); + const gapUp = rank > 0 ? ranked[rank - 1] - v : Infinity; + const gapDown = rank < ranked.length - 1 ? v - ranked[rank + 1] : Infinity; + return [c.key, gapUp >= gapDown ? 'top' : 'bottom']; + })); + }); + + const totals = periods.map((_, i) => CATEGORIES.reduce((s, c) => s + (num(dataRows[i], c.key) ?? 0), 0)); + // 轴上限随数据走:2025FY 合计已 371.2 亿,写死 250 会把柱子顶出绘图区 + const axisMax = niceAxisMax(Math.max(...totals)); + const amountInterval = axisMax % 4 === 0 ? axisMax / 4 : axisMax / 5; + // 绘图区像素高:画布高 − grid top 145 − grid bottom 90 + const pxPerUnit = (height - LAYOUT.gridTop - 90) / axisMax; + const rowOffsets = fanTinyLabelOffsets(dataRows, CATEGORIES.map((c) => c.key), pxPerUnit); return { backgroundColor: '#fff', title: { ...titleStyle, text: `泡泡玛特品类收入结构(${financialViewLabel(view)})`, - subtext: ['单位:亿元;H2为下半年单期', financialViewDisclosure(view)].filter(Boolean).join('\n'), + subtext: ['柱为收入(亿元,左轴);折线为各品类收入同比(右轴);H2为下半年单期', + financialViewDisclosure(view)].filter(Boolean).join('\n'), }, + // 图例柱与同比线交错排列,同品类的两项相邻 legend: { ...legendStyle, top: LAYOUT.legendTop, - data: ['毛绒', '手办', 'MEGA', '衍生品及其他'], + data: CATEGORIES.flatMap((c) => [c.name, `${c.name}同比`]), }, - grid: { left: 78, right: 56, top: LAYOUT.gridTop, bottom: 90 }, + grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 }, tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, textStyle: { fontFamily: FONT }, formatter: (params) => { + const bars = params.filter((p) => p.seriesType === 'bar' && Number.isFinite(p.value)); + const lines = params.filter((p) => p.seriesType === 'line' && Number.isFinite(p.value)); + const total = bars.reduce((s, p) => s + p.value, 0); let result = `${params[0].axisValue}
`; - let total = 0; - params.forEach(p => { - total += p.value; - result += `${p.marker}${p.seriesName}: ${p.value.toFixed(2)} 亿元
`; + bars.forEach((p) => { result += `${p.marker}${p.seriesName}: ${p.value.toFixed(2)} 亿元
`; }); + result += `合计: ${total.toFixed(2)} 亿元
`; + lines.forEach((p) => { + result += `${p.marker}${p.seriesName}: ${p.value > 0 ? '+' : ''}${p.value.toFixed(1)}%
`; }); - result += `合计: ${total.toFixed(2)} 亿元`; return result; }, }, xAxis: { ...standalonePeriodAxis, data: periods }, - yAxis: { - ...axisCommon, - type: 'value', - name: '收入(亿元)', - nameLocation: 'middle', - nameGap: 52, - nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, - min: 0, - max: 250, - interval: 50, - }, + yAxis: [ + { + ...axisCommon, + type: 'value', + name: '收入(亿元)', + nameLocation: 'middle', + nameGap: 52, + nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, + min: 0, + max: axisMax, + interval: amountInterval, + }, + { + ...axisCommon, + type: 'value', + name: '收入同比(%)', + nameLocation: 'middle', + nameGap: 56, + nameRotate: -90, + nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, + min: yoyMin, + max: yoyMax, + interval: yoyInterval, + axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' }, + splitLine: { show: false }, + }, + ], series: [ - { - name: '毛绒', + ...CATEGORIES.map((c, ci) => ({ + name: c.name, type: 'bar', stack: 'category', - data: col(dataRows, 'plush'), - itemStyle: { color: PALETTE.coral }, + // 每段都标数值:矮段外置到柱体右侧,机制同渠道金额图 + data: dataRows.map((row, ri) => stackedSharePoint(row[c.key], { + pxPerUnit, + labelColor: '#fff', + dy: rowOffsets[ri][ci] ?? 0, + })), + itemStyle: { color: c.color }, emphasis: { focus: 'series' }, label: { show: true, @@ -68,61 +152,30 @@ export function categoryChart(rows, { width, height, view = 'all' }) { color: '#fff', fontSize: 12, fontFamily: FONT, - formatter: (p) => (p.value > 15 ? p.value.toFixed(1) : ''), + overflow: 'none', + formatter: (p) => `${p.value.toFixed(1)}`, }, - }, - { - name: '手办', - type: 'bar', - stack: 'category', - data: col(dataRows, 'figure'), - itemStyle: { color: PALETTE.blue }, - emphasis: { focus: 'series' }, - label: { - show: true, - position: 'inside', - color: '#fff', - fontSize: 12, - fontFamily: FONT, - formatter: (p) => (p.value > 15 ? p.value.toFixed(1) : ''), - }, - }, - { - name: 'MEGA', - type: 'bar', - stack: 'category', - data: col(dataRows, 'mega'), - itemStyle: { color: PALETTE.purple }, - emphasis: { focus: 'series' }, - label: { - show: true, - position: 'inside', - color: '#fff', - fontSize: 12, - fontFamily: FONT, - formatter: (p) => (p.value > 8 ? p.value.toFixed(1) : ''), - }, - }, - { - name: '衍生品及其他', - type: 'bar', - stack: 'category', - data: col(dataRows, 'other'), - itemStyle: { color: PALETTE.sage }, - emphasis: { focus: 'series' }, - label: { - show: true, - position: 'inside', - color: '#fff', - fontSize: 12, - fontFamily: FONT, - formatter: (p) => (p.value > 8 ? p.value.toFixed(1) : ''), - }, - }, + })), + ...CATEGORIES.map((c) => ({ + ...lineSeries({ + name: `${c.name}同比`, + data: yoyByCat[c.key], + color: c.lineColor, + formatter: (p) => `${p.value.toFixed(1)}%`, + width: 2.5, + }), + yAxisIndex: 1, + z: 10, + // label.position 不接受函数,逐点覆盖(避让规则见 yoyLabelPos) + data: yoyByCat[c.key].map((v, i) => (Number.isFinite(v) + ? { value: v, label: { position: yoyLabelPos[i][c.key], distance: 10 } } + : null)), + labelLayout: { hideOverlap: true }, + })), ], graphic: [ ...watermarks(width, height), - footnote('数据来源:泡泡玛特财报 | @Joey知行记'), + footnote('数据来源:泡泡玛特财报;同比=当期÷上年同期−1\n2024年各期无上年基数,不计算同比 | @Joey知行记'), ], }; } diff --git a/js/charts/ppmt_channel_revenue.js b/js/charts/ppmt_channel_revenue.js index e899c18..fa22015 100644 --- a/js/charts/ppmt_channel_revenue.js +++ b/js/charts/ppmt_channel_revenue.js @@ -1,7 +1,7 @@ import { col } from '../csv.js'; import { CHANNEL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, legendStyle, - financialViewDisclosure, financialViewLabel, + financialViewDisclosure, financialViewLabel, niceAxisMax, lineSeries, footnote, watermarks, LAYOUT, } from '../theme.js'; @@ -23,14 +23,12 @@ const INSIDE_LABEL_COLORS = { 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 axisMax = niceAxisMax(totalMax); const yoyValues = col(rows, 'revenue_yoy').filter(Number.isFinite); // 全部/半年视图存在 2022H2 的负增长,右轴需包含负区间,避免折线被 0 轴裁切。 const yoyMin = Math.min(0, Math.floor(Math.min(...yoyValues) / 50) * 50); diff --git a/js/charts/ppmt_expense_structure.js b/js/charts/ppmt_expense_structure.js index a7dc3f8..944ca2b 100644 --- a/js/charts/ppmt_expense_structure.js +++ b/js/charts/ppmt_expense_structure.js @@ -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: '四项费用率', diff --git a/js/charts/ppmt_ip.js b/js/charts/ppmt_ip.js index f7cfd16..cec6233 100644 --- a/js/charts/ppmt_ip.js +++ b/js/charts/ppmt_ip.js @@ -1,7 +1,7 @@ import { col } from '../csv.js'; import { PALETTE, FONT, axisCommon, categoryAxis, titleStyle, legendStyle, - financialViewDisclosure, financialViewLabel, shortStandalonePeriod, + financialViewDisclosure, financialViewLabel, shortStandalonePeriod, niceAxisMax, lineSeries, footnote, watermarks, LAYOUT, } from '../theme.js'; @@ -39,6 +39,10 @@ export function ipChart(rows, { width, height, view = 'all' }) { }); }); + // 轴上限随数据走(THE MONSTERS 2025 已超 140 亿),写死会把爆款折线顶出绘图区 + const values = rows.flatMap((row) => periods.map((p) => row[p])).filter(Number.isFinite); + const axisMax = niceAxisMax(Math.max(...values)); + return { backgroundColor: '#fff', title: { @@ -74,8 +78,8 @@ export function ipChart(rows, { width, height, view = 'all' }) { nameGap: 52, nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, min: 0, - max: 100, - interval: 20, + max: axisMax, + interval: axisMax % 4 === 0 ? axisMax / 4 : axisMax / 5, }, series, graphic: [ diff --git a/js/charts/ppmt_ip_share.js b/js/charts/ppmt_ip_share.js index ead3f78..aa4915b 100644 --- a/js/charts/ppmt_ip_share.js +++ b/js/charts/ppmt_ip_share.js @@ -5,7 +5,7 @@ import { /** * 泡泡玛特主要 IP 收入占比(2026H1) * 饼图(非环图),图例放在右侧:每项上行 IP 名、下行小字显示收入和占比。 - * 扇区外直接标注 IP 名 + 数值(两行)。源:ppmt.xlsx「IP收入和占比」2026H1 列, + * 扇区外直接标注 IP 名 + 数值/占比(两行),IP 名与扇区同色。源:ppmt.xlsx「IP收入和占比」2026H1 列, * HACIPUPU、BUNNY、PUCKY 等该期无数据的 IP 不列入。 * 占比为衍生指标:各 IP 收入 ÷ 全部列入 IP 收入合计(源表占比列未填)。 * @@ -29,18 +29,28 @@ const COLORS = [ ]; export function ppmtIpShareChart(rows, { width, height }) { - const data = rows.map((row, i) => ({ - name: row.ip, - value: row.revenue, - itemStyle: { color: COLORS[i % COLORS.length] }, - })); + const data = rows.map((row, i) => { + const color = COLORS[i % COLORS.length]; + return { + name: row.ip, + value: row.revenue, + itemStyle: { color }, + // 逐扇区覆盖标签 rich:IP 名与所在区块同色,其余样式沿用系列级配置 + label: { + rich: { + name: { fontFamily: FONT, fontSize: 12, fontWeight: 600, color, lineHeight: 16 }, + value: { fontFamily: FONT, fontSize: 11, color: PALETTE.grey, lineHeight: 15 }, + }, + }, + }; + }); const total = data.reduce((s, d) => s + d.value, 0); const byName = Object.fromEntries(data.map((d) => [d.name, d])); // 把「饼图 + 两侧外标签」当成一个整体,与图例并成一组居中摆放: // 图例紧跟在右侧标签外缘之后,不会被推到画布边缘留下大片空白 const LEGEND_WIDTH = 152; // 最宽一行「17.7 亿元 · 10.3%」约 120px + 色块 - const LEGEND_GAP = 24; // 右侧标签外缘 → 图例 + const LEGEND_GAP = 56; // 右侧标签外缘 → 图例;最长数值行会侵入该间隙,实测 24px 太挤 // 左右标签占位不对称:左侧文字向左延伸(text-anchor: end)要占满, // 右侧只需容下引导线 + 首行文字起段,留太多会在图例前空出一大块 const LABEL_LEFT = 135; @@ -105,7 +115,7 @@ export function ppmtIpShareChart(rows, { width, height }) { data, label: { show: true, - formatter: (p) => `{name|${p.name}}\n{value|${p.value.toFixed(1)} 亿元}`, + formatter: (p) => `{name|${p.name}}\n{value|${p.value.toFixed(1)} 亿元 / ${p.percent.toFixed(1)}%}`, rich: { name: { fontFamily: FONT, fontSize: 12, color: PALETTE.ink, fontWeight: 600, lineHeight: 16 }, value: { fontFamily: FONT, fontSize: 11, color: PALETTE.grey, lineHeight: 15 }, diff --git a/js/charts/ppmt_ip_share_history.js b/js/charts/ppmt_ip_share_history.js index feed01a..3ea2bfa 100644 --- a/js/charts/ppmt_ip_share_history.js +++ b/js/charts/ppmt_ip_share_history.js @@ -1,7 +1,7 @@ import { PALETTE, FONT, axisCommon, categoryAxis, titleStyle, financialViewDisclosure, financialViewLabel, shortStandalonePeriod, - standardLegend, barSeries, footnote, watermarks, LAYOUT, + standardLegend, barSeries, fanTinyLabelOffsets, stackedSharePoint, footnote, watermarks, LAYOUT, } from '../theme.js'; /** @@ -40,8 +40,14 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) { const periods = Object.keys(rows[0]).filter((k) => k !== 'ip'); const names = rows.map((r) => r.ip); const totals = periods.map((p) => rows.reduce((s, r) => s + r[p], 0)); - // 归一化:各 IP 占当期合计的比重,逐期加总恰为 100% - const toPct = (r) => periods.map((p, i) => (r[p] / totals[i]) * 100); + // 归一化:各 IP 占当期合计的比重,逐期加总恰为 100%。 + // 转成「期间 × 系列」的行结构,供 fanTinyLabelOffsets 逐柱计算矮段偏移。 + const pctRows = periods.map((p, i) => Object.fromEntries( + rows.map((r) => [r.ip, (r[p] / totals[i]) * 100]), + )); + // 绘图区像素高:画布高 − grid top 145 − grid bottom 70;轴固定 0–100% + const pxPerUnit = (height - LAYOUT.gridTop - 70) / 100; + const rowOffsets = fanTinyLabelOffsets(pctRows, names, pxPerUnit); return { backgroundColor: '#fff', @@ -83,9 +89,14 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) { interval: 20, axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' }, }, - series: rows.map((r) => barSeries({ + // 每个非零占比都要展示:塞得进段内的段内白字,矮段外置到柱体右侧(深字白描边) + series: rows.map((r, ci) => barSeries({ name: r.ip, - data: toPct(r), + data: pctRows.map((pr, ri) => stackedSharePoint(pr[r.ip], { + pxPerUnit, + labelColor: '#fff', + dy: rowOffsets[ri][ci] ?? 0, + })), color: COLORS[r.ip] ?? PALETTE.grey, barWidth: 40, stack: 'share', @@ -96,8 +107,8 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) { fontSize: 11, fontFamily: FONT, fontWeight: 500, - // 低于 4% 的段塞不进字,留空靠 tooltip 读数 - formatter: (p) => (p.value >= 4 ? `${p.value.toFixed(1)}%` : ''), + overflow: 'none', + formatter: (p) => `${p.value.toFixed(1)}%`, }, emphasis: { focus: 'series' }, })), diff --git a/js/charts/ppmt_region_channel.js b/js/charts/ppmt_region_channel.js deleted file mode 100644 index d74a9a2..0000000 --- a/js/charts/ppmt_region_channel.js +++ /dev/null @@ -1,126 +0,0 @@ -import { - PALETTE, FONT, titleStyle, footnote, watermarks, legendStyle, -LAYOUT, } from '../theme.js'; - -/** - * 泡泡玛特区域渠道分布图(2026H1) - * 使用交错正负轴展示不同区域和渠道的收入 - */ -export function regionChannelChart(rows, { width, height }) { - // 数据结构重组:按区域分组 - const regions = ['中国', '海外', '亚太', '美洲', '欧洲及其他']; - const channels = ['线下渠道', '线上渠道', '批发及其他']; - - // 构建数据映射 - const dataMap = {}; - rows.forEach(row => { - const key = `${row.region}_${row.channel}`; - dataMap[key] = row.value; - }); - - // 为每个渠道构建系列数据 - const seriesData = channels.map(channel => { - return regions.map(region => { - const key = `${region}_${channel}`; - const value = dataMap[key] || 0; - // 线上渠道显示为负值(左侧),其他为正值(右侧) - return channel === '线上渠道' ? -value : value; - }); - }); - - // 配色 - const channelColors = { - '线下渠道': PALETTE.navy, - '线上渠道': PALETTE.coral, - '批发及其他': PALETTE.sage, - }; - - return { - backgroundColor: '#fff', - title: { - ...titleStyle, - text: '泡泡玛特区域渠道分布(2026H1)', - subtext: '左侧:线上渠道 | 右侧:线下渠道、批发及其他,单位:亿元', - top: 18, - }, - legend: { - ...legendStyle, - data: channels, - top: LAYOUT.legendTop, - itemGap: 32, - textStyle: { fontFamily: FONT, fontSize: 13 }, - }, - tooltip: { - trigger: 'axis', - axisPointer: { type: 'shadow' }, - textStyle: { fontFamily: FONT }, - formatter: (params) => { - let result = `${params[0].axisValue}
`; - params.forEach(p => { - const value = Math.abs(p.value); - result += `${p.marker}${p.seriesName}: ${value.toFixed(2)} 亿元
`; - }); - return result; - }, - }, - grid: { - left: 100, - right: 100, - top: 145, - bottom: 90, - containLabel: false, - }, - xAxis: { - type: 'value', - position: 'top', - splitLine: { - show: true, - lineStyle: { color: '#e8e8e8', width: 1 } - }, - axisLine: { show: false }, - axisTick: { show: false }, - axisLabel: { - formatter: (value) => Math.abs(value), - fontFamily: FONT, - fontSize: 12, - color: '#8c8c8c', - }, - }, - yAxis: { - type: 'category', - data: regions, - axisLine: { show: false }, - axisTick: { show: false }, - axisLabel: { - fontFamily: FONT, - fontSize: 13, - color: '#333', - fontWeight: '500', - }, - inverse: false, - }, - series: channels.map((channel, idx) => ({ - name: channel, - type: 'bar', - stack: channel === '线上渠道' ? 'left' : 'right', - data: seriesData[idx], - itemStyle: { color: channelColors[channel] }, - label: { - show: true, - position: channel === '线上渠道' ? 'left' : 'right', - formatter: (params) => { - const value = Math.abs(params.value); - return value > 0 ? value.toFixed(1) : ''; - }, - fontFamily: FONT, - fontSize: 11, - color: '#333', - }, - barWidth: 22, - })), - graphic: [ - ...watermarks(width, height), - footnote('数据来源:泡泡玛特财报 | @Joey知行记'), - ], - }; -} diff --git a/js/charts/ppmt_retail_store_revenue.js b/js/charts/ppmt_retail_store_revenue.js index c37c997..36ffe2d 100644 --- a/js/charts/ppmt_retail_store_revenue.js +++ b/js/charts/ppmt_retail_store_revenue.js @@ -4,11 +4,13 @@ import { financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT, } from '../theme.js'; +// 同比虚线用柱色的同色系深色变体:压在柱体和彼此之间都能区分,色相仍与柱对应 const REGIONS = [ - { key: 'china', name: '中国(含港澳台)', color: '#3F7FBE' }, - { key: 'apac', name: '亚太', color: '#36A28F' }, - { key: 'americas', name: '美洲', color: '#E4A349' }, - { key: 'europe_other', name: '欧洲及其他', color: PALETTE.brown }, + { key: 'china', name: '中国(含港澳台)', color: '#3F7FBE', lineColor: PALETTE.navy }, + // 亚太青、美洲金、深棕在色板无对应深色,按柱色加深或取邻近深色 + { key: 'apac', name: '亚太', color: '#36A28F', lineColor: '#1F6E5F' }, + { key: 'americas', name: '美洲', color: '#E4A349', lineColor: PALETTE.orange }, + { key: 'europe_other', name: '欧洲及其他', color: PALETTE.brown, lineColor: '#4A3117' }, ]; function yoyByPeriod(rows, key) { @@ -37,7 +39,7 @@ export function retailStoreRevenueChart(rows, { width, height, view = 'all' }) { title: { ...titleStyle, text: `泡泡玛特各地区零售店单店收入(${financialViewLabel(view)})`, - subtext: '柱状为单店收入;同色虚线为单店收入同比;H2为下半年单期', + subtext: '柱状为单店收入;深色虚线为对应柱的单店收入同比;H2为下半年单期', }, legend: standardLegend(legendItems, { itemGap: 18 }), grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 }, @@ -93,12 +95,12 @@ export function retailStoreRevenueChart(rows, { width, height, view = 'all' }) { ...lineSeries({ name: `${region.name}同比`, data: region.data, - color: region.color, + color: region.lineColor, width: 2, formatter: (point) => `${point.value.toFixed(0)}%`, }), yAxisIndex: 1, - lineStyle: { color: region.color, width: 2, type: 'dashed' }, + lineStyle: { color: region.lineColor, width: 2, type: 'dashed' }, z: 10, })), ], diff --git a/js/charts/ppmt_robot_store_revenue.js b/js/charts/ppmt_robot_store_revenue.js index 89ea8be..472d659 100644 --- a/js/charts/ppmt_robot_store_revenue.js +++ b/js/charts/ppmt_robot_store_revenue.js @@ -4,9 +4,11 @@ import { financialViewLabel, barSeries, lineSeries, footnote, watermarks, LAYOUT, } from '../theme.js'; +// 同比虚线用柱色的同色系深色变体:压在柱体和彼此之间都能区分,色相仍与柱对应 const REGIONS = [ - { key: 'china', name: '中国(含港澳台)', color: '#71AD81' }, - { key: 'overseas', name: '海外', color: PALETTE.brown }, + { key: 'china', name: '中国(含港澳台)', color: '#71AD81', lineColor: PALETTE.green }, + // 色板没有深棕,按 brown 加深 + { key: 'overseas', name: '海外', color: PALETTE.brown, lineColor: '#4A3117' }, ]; function yoyByPeriod(rows, key) { @@ -35,7 +37,7 @@ export function robotStoreRevenueChart(rows, { width, height, view = 'all' }) { title: { ...titleStyle, text: `泡泡玛特机器人商店单店收入(${financialViewLabel(view)})`, - subtext: '柱状为单店收入;同色虚线为单店收入同比;H2为下半年单期', + subtext: '柱状为单店收入;深色虚线为对应柱的单店收入同比;H2为下半年单期', }, legend: standardLegend(legendItems, { itemGap: 18 }), grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 }, @@ -91,12 +93,12 @@ export function robotStoreRevenueChart(rows, { width, height, view = 'all' }) { ...lineSeries({ name: `${region.name}同比`, data: region.data, - color: region.color, + color: region.lineColor, width: 2, formatter: (point) => `${point.value.toFixed(0)}%`, }), yAxisIndex: 1, - lineStyle: { color: region.color, width: 2, type: 'dashed' }, + lineStyle: { color: region.lineColor, width: 2, type: 'dashed' }, z: 10, })), ], diff --git a/js/theme.js b/js/theme.js index ce2c82f..92f0257 100644 --- a/js/theme.js +++ b/js/theme.js @@ -161,6 +161,15 @@ export const LAYOUT = { gridTop: 145, // 绘图区顶部边距(图例到绘图区约 60px) }; +/** 数值轴整齐上限候选:轴 max 从中取第一个能容纳数据的刻度 */ +export const NICE_AXIS_MAX = [20, 25, 50, 60, 80, 100, 120, 150, 200, 250, 300, 400, 500, 600, 800, 1000]; + +/** 按数据最大值取整齐轴上限;超出候选表尾时按 100 的整数倍向上取整并留 20% 余量 */ +export function niceAxisMax(dataMax) { + return NICE_AXIS_MAX.find((max) => max >= dataMax * 1.02) + ?? Math.ceil((dataMax * 1.2) / 100) * 100; +} + // 统一图例图标宽度,同时保留 ECharts 按系列类型生成的形状。 export const LEGEND_ITEM_WIDTH = 20; diff --git a/manifest.json b/manifest.json index 30f8efe..9611bfe 100644 --- a/manifest.json +++ b/manifest.json @@ -18,7 +18,6 @@ "ppmt-ip-share-history": { "key": "ppmt-ip-share-history", "title": "各 IP 半年收入占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_ip_share_history.csv", "annual": "data/ppmt/ppmt_ip_share_history_annual.csv", "halfyear": "data/ppmt/ppmt_ip_share_history_halfyear.csv" }, "height": 700, "filename": "泡泡玛特各IP半年收入占比" }, "ppmt-ip-ex-monsters": { "key": "ppmt-ip-ex-monsters", "title": "除 THE MONSTERS 外的 IP 收入及同比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_ip_ex_monsters.csv", "annual": "data/ppmt/ppmt_ip_ex_monsters_annual.csv", "halfyear": "data/ppmt/ppmt_ip_ex_monsters_halfyear.csv" }, "height": 700, "filename": "泡泡玛特除MONSTERS外IP收入及同比" }, "ppmt-category": { "key": "ppmt-category", "title": "品类收入结构", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_category.csv", "annual": "data/ppmt/ppmt_category_annual.csv", "halfyear": "data/ppmt/ppmt_category_halfyear.csv" }, "height": 680, "filename": "泡泡玛特品类结构" }, - "ppmt-region-channel": { "key": "ppmt-region-channel", "title": "区域渠道分布", "csv": "data/ppmt/ppmt_region_channel_2026h1.csv", "height": 680, "filename": "泡泡玛特区域渠道分布2026H1" }, "ppmt-region-yoy": { "key": "ppmt-region-yoy", "title": "区域渠道同比增速", "csv": "data/ppmt/ppmt_region_channel_yoy.csv", "height": 620, "filename": "泡泡玛特区域渠道同比增速" }, "ppmt-region-yoy-timeseries": { "key": "ppmt-region-yoy-timeseries", "title": "区域渠道同比增速趋势", "csv": "data/ppmt/ppmt_region_channel_yoy_timeseries.csv", "height": 760, "filename": "泡泡玛特区域渠道同比增速趋势" }, "ppmt-cash-conversion": { "key": "ppmt-cash-conversion", "title": "经营现金流与净利润", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_cash_conversion.csv", "annual": "data/ppmt/ppmt_cash_conversion_annual.csv", "halfyear": "data/ppmt/ppmt_cash_conversion_halfyear.csv" }, "height": 700, "filename": "泡泡玛特经营现金流与净利润" }, @@ -44,12 +43,12 @@ "ppmt-region-share": { "key": "ppmt-region-share", "title": "分地区营收占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_region_share.csv", "annual": "data/ppmt/ppmt_region_share_annual.csv", "halfyear": "data/ppmt/ppmt_region_share_halfyear.csv" }, "height": 680, "filename": "泡泡玛特分地区营收占比" } }, "tabs": [ - { "id": "overview", "name": "概览", "chartRefs": ["ppmt-growth", "ppmt-profit", "ppmt-ip-share", "ppmt-region-channel"] }, + { "id": "overview", "name": "概览", "chartRefs": ["ppmt-growth", "ppmt-profit", "ppmt-ip-share"] }, { "id": "profitability", "name": "盈利能力", "chartRefs": ["ppmt-growth", "ppmt-core-profit", "ppmt-core-margin", "ppmt-bridge", "ppmt-sankey", "ppmt-cash-conversion", "ppmt-cashflow-composition"] }, { "id": "expense", "name": "费用率", "chartRefs": ["ppmt-margins", "ppmt-expense-structure"] }, { "id": "balance-sheet", "name": "资产负债", "chartRefs": ["ppmt-assets", "ppmt-inventory", "ppmt-liquidity-working-capital", "ppmt-working-capital-days"] }, { "id": "ip-category", "name": "IP 与品类", "chartRefs": ["ppmt-ip", "ppmt-ip-share", "ppmt-ip-share-history", "ppmt-ip-ex-monsters", "ppmt-category"] }, - { "id": "region-channel", "name": "区域渠道", "chartRefs": ["ppmt-region-channel", "ppmt-region-yoy", "ppmt-region-yoy-timeseries", "ppmt-channel-revenue", "ppmt-channel-share", "ppmt-channel-share-china", "ppmt-channel-amount-china", "ppmt-channel-share-apac", "ppmt-channel-amount-apac", "ppmt-channel-share-americas", "ppmt-channel-amount-americas", "ppmt-channel-share-europe", "ppmt-channel-amount-europe", "ppmt-stores-retail", "ppmt-stores-retail-share", "ppmt-stores-robot", "ppmt-region-share"] }, + { "id": "region-channel", "name": "区域渠道", "chartRefs": ["ppmt-region-yoy", "ppmt-region-yoy-timeseries", "ppmt-channel-revenue", "ppmt-channel-share", "ppmt-channel-share-china", "ppmt-channel-amount-china", "ppmt-channel-share-apac", "ppmt-channel-amount-apac", "ppmt-channel-share-americas", "ppmt-channel-amount-americas", "ppmt-channel-share-europe", "ppmt-channel-amount-europe", "ppmt-stores-retail", "ppmt-stores-retail-share", "ppmt-stores-robot", "ppmt-region-share"] }, { "id": "store-revenue", "name": "单店收入", "chartRefs": ["ppmt-retail-store-revenue", "ppmt-robot-store-revenue"] } ] },