From 2555453b79e138ca6415b818428d03f66284fa0f Mon Sep 17 00:00:00 2001 From: R524809 Date: Mon, 31 Aug 2026 16:31:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(ppmt):=20=E6=96=B0=E5=A2=9E=E5=88=86?= =?UTF-8?q?=E5=9C=B0=E5=8C=BA=E6=94=B6=E5=85=A5=E5=8D=A0=E6=AF=94=E4=B8=8E?= =?UTF-8?q?=E9=97=A8=E5=BA=97=E6=95=B0=E9=87=8F=E5=9B=BE=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增分地区收入占比、零售门店数量、机器人商店数量三张图表及对应 CSV 数据 - 渠道收入图矮段标签外置并扇形排布,避免段内标签挤压 - IP 收入占比图改用 standardLegend 统一图例样式 - 折线标注增加白底描边,提升可读性 --- data/ppmt/ppmt_region_share.csv | 8 ++ data/ppmt/ppmt_region_share_annual.csv | 3 + data/ppmt/ppmt_region_share_halfyear.csv | 6 + data/ppmt/ppmt_stores_retail.csv | 13 ++ data/ppmt/ppmt_stores_retail_annual.csv | 10 ++ data/ppmt/ppmt_stores_retail_halfyear.csv | 4 + data/ppmt/ppmt_stores_robot.csv | 13 ++ data/ppmt/ppmt_stores_robot_annual.csv | 10 ++ data/ppmt/ppmt_stores_robot_halfyear.csv | 4 + js/charts/index.js | 6 + js/charts/ppmt_channel_revenue.js | 102 +++++++++++++-- js/charts/ppmt_ip_share_history.js | 29 +---- js/charts/ppmt_region_share.js | 111 +++++++++++++++++ js/charts/ppmt_stores_retail.js | 134 ++++++++++++++++++++ js/charts/ppmt_stores_robot.js | 144 ++++++++++++++++++++++ js/theme.js | 4 +- manifest.json | 7 +- 17 files changed, 573 insertions(+), 35 deletions(-) create mode 100644 data/ppmt/ppmt_region_share.csv create mode 100644 data/ppmt/ppmt_region_share_annual.csv create mode 100644 data/ppmt/ppmt_region_share_halfyear.csv create mode 100644 data/ppmt/ppmt_stores_retail.csv create mode 100644 data/ppmt/ppmt_stores_retail_annual.csv create mode 100644 data/ppmt/ppmt_stores_retail_halfyear.csv create mode 100644 data/ppmt/ppmt_stores_robot.csv create mode 100644 data/ppmt/ppmt_stores_robot_annual.csv create mode 100644 data/ppmt/ppmt_stores_robot_halfyear.csv create mode 100644 js/charts/ppmt_region_share.js create mode 100644 js/charts/ppmt_stores_retail.js create mode 100644 js/charts/ppmt_stores_robot.js diff --git a/data/ppmt/ppmt_region_share.csv b/data/ppmt/ppmt_region_share.csv new file mode 100644 index 0000000..42555cc --- /dev/null +++ b/data/ppmt/ppmt_region_share.csv @@ -0,0 +1,8 @@ +period,china,apac,americas,europe,total +2024H1,35.22,7.97,1.82,0.58,45.59 +2024H2,53.65,23.13,6.20,1.81,84.79 +2024FY,88.86,31.10,8.02,2.39,130.37 +2025H1,82.83,28.51,22.65,4.77,138.76 +2025H2,125.69,51.60,45.41,9.74,232.44 +2025FY,208.52,80.11,68.06,14.51,371.20 +2026H1,122.01,25.75,18.92,5.06,171.74 diff --git a/data/ppmt/ppmt_region_share_annual.csv b/data/ppmt/ppmt_region_share_annual.csv new file mode 100644 index 0000000..50c61c8 --- /dev/null +++ b/data/ppmt/ppmt_region_share_annual.csv @@ -0,0 +1,3 @@ +period,china,apac,americas,europe,total +2024FY,88.86,31.10,8.02,2.39,130.37 +2025FY,208.52,80.11,68.06,14.51,371.20 diff --git a/data/ppmt/ppmt_region_share_halfyear.csv b/data/ppmt/ppmt_region_share_halfyear.csv new file mode 100644 index 0000000..f55b959 --- /dev/null +++ b/data/ppmt/ppmt_region_share_halfyear.csv @@ -0,0 +1,6 @@ +period,china,apac,americas,europe,total +2024H1,35.22,7.97,1.82,0.58,45.59 +2024H2,53.65,23.13,6.20,1.81,84.79 +2025H1,82.83,28.51,22.65,4.77,138.76 +2025H2,125.69,51.60,45.41,9.74,232.44 +2026H1,122.01,25.75,18.92,5.06,171.74 diff --git a/data/ppmt/ppmt_stores_retail.csv b/data/ppmt/ppmt_stores_retail.csv new file mode 100644 index 0000000..cc9a4c4 --- /dev/null +++ b/data/ppmt/ppmt_stores_retail.csv @@ -0,0 +1,13 @@ +period,mainland,hkmtw,apac,americas,europe,overseas,global,global_yoy +2017FY,32,,,,,,32, +2018FY,63,,,,,,63,96.9 +2019FY,114,,,,,,114,81.0 +2020FY,187,,,,,,187,64.0 +2021FY,288,,,,,7,295,57.8 +2022FY,329,,,,,43,372,26.1 +2023FY,363,,,,,70,433,16.4 +2024H1,374,25,39,10,9,58,457, +2024FY,401,30,54,22,14,90,521,20.3 +2025H1,409,34,69,41,18,128,571,24.9 +2025FY,410,35,85,64,36,185,630,20.9 +2026H1,419,36,90,86,45,221,676,18.4 diff --git a/data/ppmt/ppmt_stores_retail_annual.csv b/data/ppmt/ppmt_stores_retail_annual.csv new file mode 100644 index 0000000..628e32f --- /dev/null +++ b/data/ppmt/ppmt_stores_retail_annual.csv @@ -0,0 +1,10 @@ +period,mainland,hkmtw,apac,americas,europe,overseas,global,global_yoy +2017FY,32,,,,,,32, +2018FY,63,,,,,,63,96.9 +2019FY,114,,,,,,114,81.0 +2020FY,187,,,,,,187,64.0 +2021FY,288,,,,,7,295,57.8 +2022FY,329,,,,,43,372,26.1 +2023FY,363,,,,,70,433,16.4 +2024FY,401,30,54,22,14,90,521,20.3 +2025FY,410,35,85,64,36,185,630,20.9 diff --git a/data/ppmt/ppmt_stores_retail_halfyear.csv b/data/ppmt/ppmt_stores_retail_halfyear.csv new file mode 100644 index 0000000..7321584 --- /dev/null +++ b/data/ppmt/ppmt_stores_retail_halfyear.csv @@ -0,0 +1,4 @@ +period,mainland,hkmtw,apac,americas,europe,overseas,global,global_yoy +2024H1,374,25,39,10,9,58,457, +2025H1,409,34,69,41,18,128,571,24.9 +2026H1,419,36,90,86,45,221,676,18.4 diff --git a/data/ppmt/ppmt_stores_robot.csv b/data/ppmt/ppmt_stores_robot.csv new file mode 100644 index 0000000..26009bb --- /dev/null +++ b/data/ppmt/ppmt_stores_robot.csv @@ -0,0 +1,13 @@ +period,mainland,hkmtw,overseas,global,global_yoy +2017FY,43,,,43, +2018FY,260,,,260,504.7 +2019FY,825,,,825,217.3 +2020FY,1351,,,1351,63.8 +2021FY,1861,,9,1870,38.4 +2022FY,2067,,120,2187,17.0 +2023FY,2190,,99,2289,4.7 +2024H1,2189,35,,, +2024FY,2300,39,133,2472,8.0 +2025H1,2396,41,160,2597, +2025FY,2350,46,241,2637,6.7 +2026H1,2436,46,345,2827,8.9 diff --git a/data/ppmt/ppmt_stores_robot_annual.csv b/data/ppmt/ppmt_stores_robot_annual.csv new file mode 100644 index 0000000..034d29d --- /dev/null +++ b/data/ppmt/ppmt_stores_robot_annual.csv @@ -0,0 +1,10 @@ +period,mainland,hkmtw,overseas,global,global_yoy +2017FY,43,,,43, +2018FY,260,,,260,504.7 +2019FY,825,,,825,217.3 +2020FY,1351,,,1351,63.8 +2021FY,1861,,9,1870,38.4 +2022FY,2067,,120,2187,17.0 +2023FY,2190,,99,2289,4.7 +2024FY,2300,39,133,2472,8.0 +2025FY,2350,46,241,2637,6.7 diff --git a/data/ppmt/ppmt_stores_robot_halfyear.csv b/data/ppmt/ppmt_stores_robot_halfyear.csv new file mode 100644 index 0000000..f40625a --- /dev/null +++ b/data/ppmt/ppmt_stores_robot_halfyear.csv @@ -0,0 +1,4 @@ +period,mainland,hkmtw,overseas,global,global_yoy +2024H1,2189,35,,, +2025H1,2396,41,160,2597, +2026H1,2436,46,345,2827,8.9 diff --git a/js/charts/index.js b/js/charts/index.js index 5a3f4d4..a57a2f6 100644 --- a/js/charts/index.js +++ b/js/charts/index.js @@ -25,6 +25,9 @@ import { cashflowCompositionChart } from './ppmt_cashflow_composition.js'; import { workingCapitalDaysChart } from './ppmt_working_capital_days.js'; import { expenseStructureChart } from './ppmt_expense_structure.js'; import { channelRevenueChart } from './ppmt_channel_revenue.js'; +import { storesRetailChart } from './ppmt_stores_retail.js'; +import { storesRobotChart } from './ppmt_stores_robot.js'; +import { regionShareChart } from './ppmt_region_share.js'; /** 图表构建函数注册表。manifest 只引用这里的 key,不直接引用 JS 函数。 */ export const chartRegistry = { @@ -55,6 +58,9 @@ export const chartRegistry = { 'ppmt-working-capital-days': workingCapitalDaysChart, 'ppmt-expense-structure': expenseStructureChart, 'ppmt-channel-revenue': channelRevenueChart, + 'ppmt-stores-retail': storesRetailChart, + 'ppmt-stores-robot': storesRobotChart, + 'ppmt-region-share': regionShareChart, }; export function getChartBuilder(key) { diff --git a/js/charts/ppmt_channel_revenue.js b/js/charts/ppmt_channel_revenue.js index 754a2f4..531228b 100644 --- a/js/charts/ppmt_channel_revenue.js +++ b/js/charts/ppmt_channel_revenue.js @@ -6,20 +6,77 @@ import { } from '../theme.js'; /** 泡泡玛特渠道收入结构与增速。 */ +const channels = [ + { key: 'retail', name: '零售店', color: CHANNEL_COLORS.retail }, + { key: 'online', name: '线上', color: CHANNEL_COLORS.online }, + { key: 'robot_store', name: '机器人商店', color: CHANNEL_COLORS.robotStore }, + { key: 'wholesale', name: '批发', color: CHANNEL_COLORS.wholesale }, +]; + +// 段内标签文字色:浅色底(批发金)用深字,其余深色底用白字 +const INSIDE_LABEL_COLORS = { + retail: '#fff', + online: '#fff', + robot_store: '#fff', + wholesale: PALETTE.ink, +}; + +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 channels = [ - { key: 'retail', name: '零售店', color: CHANNEL_COLORS.retail }, - { key: 'online', name: '线上', color: CHANNEL_COLORS.online }, - { key: 'robot_store', name: '机器人商店', color: CHANNEL_COLORS.robotStore }, - { key: 'wholesale', name: '批发', color: CHANNEL_COLORS.wholesale }, - ]; + 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; + // 绘图区像素高:画布高 − grid top 145 − grid bottom 90 + const pxPerUnit = (height - LAYOUT.gridTop - 90) / axisMax; + + // 每根柱里放不下的矮段:标签外置到柱体右侧。同一柱内多个矮段标签 + // 自下而上保持 12px 间距;整根柱都是矮段时,纵向扇形以柱身中部为中心, + // 避免标签整体飘离柱顶。返回 { 渠道序号: offset dy }(dy 为像素,正方向向下)。 + const fanOutsideLabels = (row) => { + const values = channels.map((item) => num(row[item.key])); + const centers = []; + let cum = 0; + values.forEach((value) => { + centers.push((cum + value / 2) * pxPerUnit); + cum += value; + }); + const barTopPx = cum * pxPerUnit; + const tinyIdx = channels + .map((_, i) => i) + .filter((i) => values[i] !== null && values[i] * pxPerUnit < MIN_INSIDE_PX); + if (tinyIdx.length === 0) return {}; + const ys = tinyIdx.map((i) => centers[i]); + for (let k = 1; k < ys.length; k += 1) { + ys[k] = Math.max(ys[k], ys[k - 1] + 12); + } + if (tinyIdx.length === channels.length) { + const shift = barTopPx / 2 - (ys[0] + ys[ys.length - 1]) / 2; + for (let k = 0; k < ys.length; k += 1) ys[k] += shift; + if (ys[0] < 2) { + const lift = 2 - ys[0]; + for (let k = 0; k < ys.length; k += 1) ys[k] += lift; + } + } + const offsets = {}; + tinyIdx.forEach((i, k) => { + offsets[i] = centers[i] - ys[k]; + }); + return offsets; + }; + + const rowOffsets = rows.map(fanOutsideLabels); return { backgroundColor: '#fff', title: { ...titleStyle, text: `泡泡玛特渠道收入结构与增速(${financialViewLabel(view)})`, - subtext: ['渠道收入为堆叠柱;折线为营业收入同比增速', financialViewDisclosure(view)].filter(Boolean).join('\n'), + subtext: ['渠道收入为堆叠柱,柱内及柱侧标注各渠道数值;折线为营业收入同比增速', financialViewDisclosure(view)].filter(Boolean).join('\n'), }, legend: { ...legendStyle, @@ -43,6 +100,8 @@ export function channelRevenueChart(rows, { width, height, view = 'all' }) { nameGap: 56, nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, min: 0, + max: axisMax, + interval: axisMax % 4 === 0 ? axisMax / 4 : axisMax / 5, }, { ...axisCommon, @@ -60,12 +119,37 @@ export function channelRevenueChart(rows, { width, height, view = 'all' }) { }, ], series: [ - ...channels.map((item) => ({ + ...channels.map((item, ci) => ({ name: item.name, type: 'bar', stack: 'channel-revenue', - data: col(rows, item.key), + data: rows.map((row, ri) => { + const value = num(row[item.key]); + if (value === null) return null; + const inside = value * pxPerUnit >= MIN_INSIDE_PX; + return { + value, + label: inside + ? { position: 'inside', color: INSIDE_LABEL_COLORS[item.key] } + : { + position: 'right', + color: PALETTE.ink, + distance: 3, + offset: [0, rowOffsets[ri][ci] || 0], + textBorderColor: '#fff', + textBorderWidth: 2, + }, + }; + }), itemStyle: { color: item.color }, + label: { + show: true, + fontSize: 10, + fontWeight: 500, + fontFamily: FONT, + formatter: (p) => Number(p.value).toFixed(1), + overflow: 'none', + }, emphasis: { focus: 'series' }, })), { diff --git a/js/charts/ppmt_ip_share_history.js b/js/charts/ppmt_ip_share_history.js index 9d41d00..13d7a5d 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, LEGEND_ITEM_WIDTH, + PALETTE, FONT, axisCommon, categoryAxis, titleStyle, financialViewDisclosure, financialViewLabel, shortStandalonePeriod, - barSeries, footnote, watermarks, LAYOUT, + standardLegend, barSeries, footnote, watermarks, LAYOUT, } from '../theme.js'; /** @@ -36,9 +36,6 @@ const COLORS = { '外采及其他': '#8C8C8C', }; -const LEGEND_WIDTH = 160; -const LEGEND_RIGHT = 20; - 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); @@ -53,17 +50,8 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) { text: `泡泡玛特各IP收入占比(${financialViewLabel(view)})`, subtext: ['占当期IP收入合计的比重;H2为下半年单期,非全年累计', financialViewDisclosure(view)].filter(Boolean).join('\n'), }, - legend: { - orient: 'vertical', - right: LEGEND_RIGHT, - width: LEGEND_WIDTH, - top: 'middle', - itemGap: 12, - itemWidth: LEGEND_ITEM_WIDTH, - itemHeight: 14, - data: names, - textStyle: { color: PALETTE.ink, fontSize: 12, fontFamily: FONT }, - }, + // 11 个系列较多:紧凑间距靠 ECharts 自动换行成多行 + legend: standardLegend(names, { itemGap: 12, itemWidth: 14, itemHeight: 14 }), tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, @@ -80,12 +68,7 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) { .join('
'); }, }, - grid: { - left: 78, - right: LEGEND_RIGHT + LEGEND_WIDTH + 20, - top: LAYOUT.gridTop, - bottom: 70, - }, + grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 70 }, xAxis: { ...categoryAxis, data: periods, @@ -114,7 +97,7 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) { fontFamily: FONT, fontWeight: 500, // 低于 4% 的段塞不进字,留空靠 tooltip 读数 - formatter: (p) => (p.value >= 4 ? p.value.toFixed(1) : ''), + formatter: (p) => (p.value >= 4 ? `${p.value.toFixed(1)}%` : ''), }, emphasis: { focus: 'series' }, })), diff --git a/js/charts/ppmt_region_share.js b/js/charts/ppmt_region_share.js new file mode 100644 index 0000000..9186375 --- /dev/null +++ b/js/charts/ppmt_region_share.js @@ -0,0 +1,111 @@ +import { + PALETTE, FONT, axisCommon, categoryAxis, titleStyle, standardLegend, + financialViewDisclosure, financialViewLabel, standalonePeriodAxis, + barSeries, footnote, watermarks, LAYOUT, +} from '../theme.js'; + +/** + * 泡泡玛特分地区营收占比(2024H1 起)。 + * 归一化堆叠柱状图:每根柱为当期营收合计 = 100%,看地区结构变化。 + * + * 口径:中国含港澳台,海外 = 亚太 + 美洲 + 欧洲及其他(2024 年起的新披露口径)。 + * H2 为单半年(当年披露全年 − 上半年还原),非全年累计,轴用 standalonePeriodAxis。 + * 2024H1 的新口径分地区收入未直接披露,按 2025 年中报披露的分地区同比还原 + * (2025H1 值 ÷ (1+同比)),还原后加总与当期总营收误差 < 0.1 亿。 + * + * 地区配色与同 Tab 门店数量图(ppmt_stores_retail.js)保持一致。 + */ +const REGIONS = [ + { key: 'china', name: '中国', color: '#3F7FBE' }, + { key: 'apac', name: '亚太', color: '#36A28F' }, + { key: 'americas', name: '美洲', color: '#E4A349' }, + { key: 'europe', name: '欧洲及其他', color: PALETTE.brown }, +]; + +// 浅色底(美洲金)用深字,其余深色底用白字 +const INSIDE_LABEL_COLORS = { + china: '#fff', + apac: '#fff', + americas: PALETTE.ink, + europe: '#fff', +}; + +// 绘图区像素高:画布高 − grid top 145 − grid bottom 100;轴固定 0-100%。 +// 低于约 13px 的段放不下 11px 段内标签:是柱顶段的标到柱顶上方, +// 上方还有其它段的标到柱体右侧。 +const MIN_INSIDE_PX = 13; + +function segmentLabel(row, item, ri, totals, keyIndex, pxPerPct) { + const pct = ((row[item.key] || 0) / totals[ri]) * 100; + if (pct * pxPerPct >= MIN_INSIDE_PX) { + return { value: pct, label: { position: 'inside', color: INSIDE_LABEL_COLORS[item.key] } }; + } + const hasSegmentAbove = REGIONS.slice(keyIndex + 1).some((r) => (row[r.key] || 0) > 0); + return { + value: pct, + label: hasSegmentAbove + ? { position: 'right', color: PALETTE.ink, distance: 4, textBorderColor: '#fff', textBorderWidth: 2 } + : { position: 'top', color: PALETTE.ink, distance: 4 }, + }; +} + +export function regionShareChart(rows, { width, height, view = 'all' }) { + const periods = rows.map((row) => row.period); + const totals = rows.map((row) => REGIONS.reduce((s, item) => s + (row[item.key] || 0), 0)); + const pxPerPct = (height - LAYOUT.gridTop - 100) / 100; + + return { + backgroundColor: '#fff', + title: { + ...titleStyle, + text: `泡泡玛特分地区营收占比(${financialViewLabel(view)})`, + subtext: ['归一化堆叠,每根柱为当期营收合计=100%;H2为下半年单期', financialViewDisclosure(view)].filter(Boolean).join('\n'), + }, + legend: standardLegend(REGIONS.map((item) => item.name)), + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + textStyle: { fontFamily: FONT }, + formatter: (ps) => { + const i = periods.indexOf(ps[0].axisValue); + const head = `${ps[0].axisValue} 合计 ${totals[i].toFixed(1)} 亿元
`; + return head + ps + .slice() + .reverse() + .map((p) => `${p.marker}${p.seriesName}: ${p.value.toFixed(1)}%` + + `(${((p.value / 100) * totals[i]).toFixed(1)} 亿元)`) + .join('
'); + }, + }, + grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 100 }, + xAxis: { ...standalonePeriodAxis, data: periods }, + yAxis: { + ...axisCommon, + type: 'value', + min: 0, + max: 100, + interval: 20, + axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' }, + }, + series: REGIONS.map((item, keyIndex) => barSeries({ + name: item.name, + data: rows.map((row, ri) => segmentLabel(row, item, ri, totals, keyIndex, pxPerPct)), + color: item.color, + barWidth: 40, + stack: 'region-share', + label: { + show: true, + position: 'inside', + fontSize: 11, + fontFamily: FONT, + fontWeight: 500, + formatter: (p) => `${p.value.toFixed(1)}%`, + }, + emphasis: { focus: 'series' }, + })), + graphic: [ + ...watermarks(width, height), + footnote('数据来源:泡泡玛特财报;中国含港澳台\n2024H1为按2025年中报披露的分地区同比还原;H2为单半年 | @思考的Joey'), + ], + }; +} diff --git a/js/charts/ppmt_stores_retail.js b/js/charts/ppmt_stores_retail.js new file mode 100644 index 0000000..7aa1e66 --- /dev/null +++ b/js/charts/ppmt_stores_retail.js @@ -0,0 +1,134 @@ +import { col } from '../csv.js'; +import { + PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, standardLegend, + financialViewLabel, lineSeries, footnote, watermarks, LAYOUT, +} from '../theme.js'; + +/** + * 泡泡玛特分地区零售门店数量与同比增速。 + * 堆叠柱为各期末门店数,折线为全球合计数量的同比。 + * 口径:2023年及以前"海外合计"含港澳台;2024年起港澳台单列并计入中国, + * 海外合计 = 亚太 + 美洲 + 欧洲。两段口径不可直接跨 2023/2024 对比海外。 + */ + +// 门店数是计数而非金额,保留整数(CLAUDE.md 一位小数规则针对金额/比率)。 +const SEGMENT_COLORS = { + mainland: '#3F7FBE', + hkmtw: '#AECBE8', + apac: '#36A28F', + americas: '#E4A349', + europe: PALETTE.brown, + overseas: '#C2726B', +}; + +// 段内标签文字色:浅色底(港澳台、美洲)用深字,深色底用白字 +const INSIDE_LABEL_COLORS = { + mainland: '#fff', + hkmtw: PALETTE.ink, + apac: '#fff', + americas: PALETTE.ink, + europe: '#fff', + overseas: '#fff', +}; + +const regions = [ + { key: 'mainland', name: '中国内地' }, + { key: 'hkmtw', name: '港澳台' }, + { key: 'apac', name: '亚太' }, + { key: 'americas', name: '美洲' }, + { key: 'europe', name: '欧洲' }, + { key: 'overseas', name: '海外合计(含港澳台)' }, +]; + +const num = (value) => (Number.isFinite(value) ? value : null); + +// 2024 起披露口径变为港澳台单列 + 亚太/美洲/欧洲分列;此前只有含港澳台的海外合计。 +// 同一根柱内两种披露结构互斥出现,堆叠总和始终等于全球合计。 +function stackValue(row, key) { + const split = Number.isFinite(row.apac); + if (key === 'overseas') return split ? null : num(row.overseas); + if (!split && key !== 'mainland') return null; + return num(row[key]); +} + +export function storesRetailChart(rows, { width, height, view = 'all' }) { + return { + backgroundColor: '#fff', + title: { + ...titleStyle, + text: `泡泡玛特分地区零售门店数量与同比增速(${financialViewLabel(view)})`, + subtext: '柱状为各期末零售门店数(堆叠);折线为全球合计门店数量同比', + }, + legend: standardLegend([...regions.map((item) => item.name), '全球合计同比']), + grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 100 }, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + textStyle: { fontFamily: FONT }, + }, + xAxis: { ...standalonePeriodAxis, data: col(rows, 'period') }, + yAxis: [ + { + ...axisCommon, + type: 'value', + name: '零售门店数量(家)', + nameLocation: 'middle', + nameGap: 52, + nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, + min: 0, + max: 700, + interval: 100, + }, + { + ...axisCommon, + type: 'value', + name: '全球合计同比(%)', + nameLocation: 'middle', + nameGap: 60, + nameRotate: -90, + nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, + min: 0, + max: 100, + interval: 20, + axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' }, + splitLine: { show: false }, + }, + ], + series: [ + ...regions.map((item) => ({ + name: item.name, + type: 'bar', + stack: 'stores-retail', + data: rows.map((row) => stackValue(row, item.key)), + itemStyle: { color: SEGMENT_COLORS[item.key] }, + // 每段标注门店数:浅色段用深字、深色段用白字,保证段内可读 + label: { + show: true, + position: 'inside', + fontSize: 10, + fontWeight: 500, + fontFamily: FONT, + color: INSIDE_LABEL_COLORS[item.key], + overflow: 'none', + }, + // 相邻矮段(如 2024H1 美洲/欧洲)标签贴得近时沿 y 向微移避让 + labelLayout: { moveOverlap: 'shiftY' }, + emphasis: { focus: 'series' }, + })), + { + ...lineSeries({ + name: '全球合计同比', + data: col(rows, 'global_yoy'), + color: '#8256B2', + formatter: (p) => `${p.value.toFixed(1)}%`, + }), + yAxisIndex: 1, + z: 10, + }, + ], + graphic: [ + ...watermarks(width, height), + footnote('数据来源:泡泡玛特历年公告;门店数为各期末时点数\n2023年及以前海外合计含港澳台;2024年起港澳台单列,海外=亚太+美洲+欧洲 | @思考的Joey'), + ], + }; +} diff --git a/js/charts/ppmt_stores_robot.js b/js/charts/ppmt_stores_robot.js new file mode 100644 index 0000000..076b88d --- /dev/null +++ b/js/charts/ppmt_stores_robot.js @@ -0,0 +1,144 @@ +import { col } from '../csv.js'; +import { + CHANNEL_COLORS, PALETTE, FONT, axisCommon, standalonePeriodAxis, titleStyle, + standardLegend, financialViewLabel, lineSeries, footnote, watermarks, LAYOUT, +} from '../theme.js'; + +/** + * 泡泡玛特分地区机器人商店数量与同比增速。 + * 堆叠柱为各期末机器人数,折线为全球合计数量的同比。 + * 口径:2023年及以前"海外合计"含港澳台;2024年起港澳台单列并计入中国。 + * 机器人商店仅披露内地/港澳台/海外三层,无亚太/美洲/欧洲分列。 + */ + +// 门店数是计数而非金额,保留整数。中国内地沿用渠道语义色(机器人商店=绿)。 +const SEGMENT_COLORS = { + mainland: CHANNEL_COLORS.robotStore, + hkmtw: '#AECBE8', + overseas: '#C2726B', +}; + +const regions = [ + { key: 'mainland', name: '中国内地' }, + { key: 'hkmtw', name: '港澳台' }, + { key: 'overseas', name: '海外合计(含港澳台)' }, +]; + +// 段内标签文字色:浅色底(港澳台)用深字,深色底(内地绿、海外玫红)用白字 +const INSIDE_LABEL_COLORS = { + mainland: '#fff', + hkmtw: PALETTE.ink, + overseas: '#fff', +}; + +const num = (value) => (Number.isFinite(value) ? value : null); + +// 绘图区高约 435px(grid top 145 / bottom 100,画布 680),轴上限 3000。 +// 低于约 13px 的段放不下段内 10px 标签:柱顶段标到柱顶上方, +// 上方还有其它段的标到柱体右侧,避免与上段段内标签互相挤压。 +const PX_PER_UNIT = 435 / 3000; +const MIN_INSIDE_PX = 13; + +function segmentData(row, key) { + const value = num(row[key]); + if (value === null) return null; + if (value * PX_PER_UNIT >= MIN_INSIDE_PX) { + return { value, label: { position: 'inside', color: INSIDE_LABEL_COLORS[key] } }; + } + const keyIndex = regions.findIndex((item) => item.key === key); + const hasSegmentAbove = regions + .slice(keyIndex + 1) + .some((item) => num(row[item.key]) !== null); + return { + value, + label: hasSegmentAbove + ? { position: 'right', color: PALETTE.ink, distance: 4 } + : { position: 'top', color: PALETTE.ink }, + }; +} + +export function storesRobotChart(rows, { width, height, view = 'all' }) { + return { + backgroundColor: '#fff', + title: { + ...titleStyle, + text: `泡泡玛特分地区机器人商店数量与同比增速(${financialViewLabel(view)})`, + subtext: '柱状为各期末机器人商店数(堆叠);折线为全球合计数量同比', + }, + legend: standardLegend([...regions.map((item) => item.name), '全球合计同比']), + grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 100 }, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + textStyle: { fontFamily: FONT }, + }, + xAxis: { ...standalonePeriodAxis, data: col(rows, 'period') }, + yAxis: [ + { + ...axisCommon, + type: 'value', + name: '机器人商店数量(台)', + nameLocation: 'middle', + nameGap: 52, + nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, + min: 0, + max: 3000, + interval: 500, + }, + { + ...axisCommon, + type: 'value', + name: '全球合计同比(%)', + nameLocation: 'middle', + nameGap: 60, + nameRotate: -90, + nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, + min: 0, + max: 600, + interval: 100, + axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' }, + splitLine: { show: false }, + }, + ], + series: [ + ...regions.map((item) => ({ + name: item.name, + type: 'bar', + stack: 'stores-robot', + data: rows.map((row) => segmentData(row, item.key)), + itemStyle: { color: SEGMENT_COLORS[item.key] }, + label: { + show: true, + fontSize: 10, + fontWeight: 500, + fontFamily: FONT, + overflow: 'none', + }, + // 相邻矮段标签贴近时沿 y 向微移避让 + labelLayout: { moveOverlap: 'shiftY' }, + emphasis: { focus: 'series' }, + })), + { + ...lineSeries({ + name: '全球合计同比', + data: col(rows, 'global_yoy'), + color: '#8256B2', + formatter: (p) => `${p.value.toFixed(1)}%`, + }), + yAxisIndex: 1, + z: 10, + }, + ], + graphic: [ + ...watermarks(width, height), + footnote( + [ + '数据来源:泡泡玛特历年公告;门店数为各期末时点数', + view === 'annual' + ? '海外合计2023年及以前含港澳台、其后不含 | @思考的Joey' + : '海外合计2023年及以前含港澳台、其后不含;2024H1海外数量未披露 | @思考的Joey', + ].join('\n'), + ), + ], + }; +} diff --git a/js/theme.js b/js/theme.js index 8072b8f..c22664b 100644 --- a/js/theme.js +++ b/js/theme.js @@ -176,7 +176,7 @@ export const legendStyle = { }, }; -/** 折线标注:带白底描边,避免压在网格线上看不清 */ +/** 折线标注:白底描边,避免标签被折线自身或柱体穿过时看不清 */ export function labelStyle(color, position = 'top') { return { show: true, @@ -186,6 +186,8 @@ export function labelStyle(color, position = 'top') { fontFamily: FONT, fontWeight: 500, distance: 8, + textBorderColor: '#fff', + textBorderWidth: 3, }; } diff --git a/manifest.json b/manifest.json index 5e45b10..c8ce31c 100644 --- a/manifest.json +++ b/manifest.json @@ -25,7 +25,10 @@ "ppmt-cashflow-composition": { "key": "ppmt-cashflow-composition", "title": "三大现金流与现金净增加", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_cashflow_composition.csv", "annual": "data/ppmt/ppmt_cashflow_composition_annual.csv", "halfyear": "data/ppmt/ppmt_cashflow_composition_halfyear.csv" }, "height": 700, "filename": "泡泡玛特三大现金流与现金净增加" }, "ppmt-working-capital-days": { "key": "ppmt-working-capital-days", "title": "营运资本效率", "csv": "data/ppmt/ppmt_working_capital_days.csv", "height": 700, "filename": "泡泡玛特营运资本效率" }, "ppmt-expense-structure": { "key": "ppmt-expense-structure", "title": "费用结构与费用率", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_expense_structure.csv", "annual": "data/ppmt/ppmt_expense_structure_annual.csv", "halfyear": "data/ppmt/ppmt_expense_structure_halfyear.csv" }, "height": 700, "filename": "泡泡玛特费用结构与费用率" }, - "ppmt-channel-revenue": { "key": "ppmt-channel-revenue", "title": "渠道收入结构与增速", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_channel_revenue.csv", "annual": "data/ppmt/ppmt_channel_revenue_annual.csv", "halfyear": "data/ppmt/ppmt_channel_revenue_halfyear.csv" }, "height": 720, "filename": "泡泡玛特渠道收入结构与增速" } + "ppmt-channel-revenue": { "key": "ppmt-channel-revenue", "title": "渠道收入结构与增速", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_channel_revenue.csv", "annual": "data/ppmt/ppmt_channel_revenue_annual.csv", "halfyear": "data/ppmt/ppmt_channel_revenue_halfyear.csv" }, "height": 720, "filename": "泡泡玛特渠道收入结构与增速" }, + "ppmt-stores-retail": { "key": "ppmt-stores-retail", "title": "分地区零售门店数量与同比增速", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_stores_retail.csv", "annual": "data/ppmt/ppmt_stores_retail_annual.csv", "halfyear": "data/ppmt/ppmt_stores_retail_halfyear.csv" }, "height": 680, "filename": "泡泡玛特分地区零售门店数量与同比增速" }, + "ppmt-stores-robot": { "key": "ppmt-stores-robot", "title": "分地区机器人商店数量与同比增速", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_stores_robot.csv", "annual": "data/ppmt/ppmt_stores_robot_annual.csv", "halfyear": "data/ppmt/ppmt_stores_robot_halfyear.csv" }, "height": 680, "filename": "泡泡玛特分地区机器人商店数量与同比增速" }, + "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"] }, @@ -33,7 +36,7 @@ { "id": "expense", "name": "费用率", "chartRefs": ["ppmt-margins", "ppmt-expense-structure"] }, { "id": "balance-sheet", "name": "资产负债", "chartRefs": ["ppmt-assets", "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"] } + { "id": "region-channel", "name": "区域渠道", "chartRefs": ["ppmt-region-channel", "ppmt-region-yoy", "ppmt-region-yoy-timeseries", "ppmt-channel-revenue", "ppmt-stores-retail", "ppmt-stores-robot", "ppmt-region-share"] } ] }, {