feat: 增加图片合并功能
This commit is contained in:
@@ -95,6 +95,6 @@ pnpm test # 全量,只在改了 t
|
|||||||
|
|
||||||
## 脚注
|
## 脚注
|
||||||
|
|
||||||
`footnote()` 单行约 60 字符封顶(1200px 画布),超了用 `\n` 手动断行。结尾统一 `| @思考的Joey`。
|
`footnote()` 单行约 60 字符封顶(1200px 画布),超了用 `\n` 手动断行。结尾统一 `| @Joey知行记`。
|
||||||
|
|
||||||
水印文案在 `theme.js` 的 `WATERMARK_TEXT`,改一处全局生效。
|
水印文案在 `theme.js` 的 `WATERMARK_TEXT`,改一处全局生效。
|
||||||
|
|||||||
+187
-2
@@ -28,7 +28,10 @@ button {
|
|||||||
.sidebar-title { margin: 0; }
|
.sidebar-title { margin: 0; }
|
||||||
|
|
||||||
.reload-button,
|
.reload-button,
|
||||||
.export-button {
|
.export-button,
|
||||||
|
.cart-button,
|
||||||
|
.cart-export-button,
|
||||||
|
.primary-button {
|
||||||
padding: 7px 13px;
|
padding: 7px 13px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -57,6 +60,186 @@ button {
|
|||||||
.reload-button:hover { opacity: .88; transform: rotate(-45deg); }
|
.reload-button:hover { opacity: .88; transform: rotate(-45deg); }
|
||||||
.export-button:hover { color: var(--accent); border-color: var(--accent); }
|
.export-button:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── 合并队列(购物车)────────────────────────────── */
|
||||||
|
|
||||||
|
.cart-button { font-size: 12px; }
|
||||||
|
.cart-button:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.cart-button[data-in-cart="true"] {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-bar[hidden] { display: none; }
|
||||||
|
|
||||||
|
.cart-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 18px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: min(1160px, calc(100vw - 32px));
|
||||||
|
padding: 9px 12px;
|
||||||
|
background: rgba(255, 255, 255, .97);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 6px 24px rgba(0, 0, 0, .12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 44vw;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.cart-chips::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.cart-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px 4px 3px 10px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-chip-name {
|
||||||
|
max-width: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-chip-button {
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.cart-chip-button:hover:not(:disabled) { color: var(--accent); }
|
||||||
|
.cart-chip-button:disabled { opacity: .3; cursor: default; }
|
||||||
|
|
||||||
|
.cart-divider { width: 1px; height: 22px; background: var(--line); }
|
||||||
|
|
||||||
|
.cart-modes {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 2px;
|
||||||
|
background: #f4f2ee;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-mode-button {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cart-mode-button[data-active="true"] {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, .08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-export-button { color: #fff; background: var(--accent); border-color: var(--accent); white-space: nowrap; }
|
||||||
|
.cart-export-button:hover { opacity: .9; }
|
||||||
|
.cart-clear-button { padding: 7px 8px; border: 0; background: transparent; color: var(--muted); font-size: 12px; white-space: nowrap; }
|
||||||
|
.cart-clear-button:hover { color: #a63f32; }
|
||||||
|
|
||||||
|
/* ── 合并预览弹窗 ────────────────────────────────── */
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 30;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(51, 51, 51, .45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: min(860px, 100%);
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title { margin: 0; font-size: 14px; }
|
||||||
|
.modal-notice { color: #a67c2b; font-size: 12px; }
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.modal-close:hover { color: var(--ink); }
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button { color: #fff; background: var(--accent); border-color: var(--accent); }
|
||||||
|
.primary-button:hover { opacity: .9; }
|
||||||
|
|
||||||
.app-layout {
|
.app-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 184px minmax(0, 1fr) 184px;
|
grid-template-columns: 184px minmax(0, 1fr) 184px;
|
||||||
@@ -235,7 +418,9 @@ button {
|
|||||||
.tab-button { flex: 0 0 auto; }
|
.tab-button { flex: 0 0 auto; }
|
||||||
.card { scroll-margin-top: 120px; }
|
.card { scroll-margin-top: 120px; }
|
||||||
.card-bar { align-items: flex-start; flex-wrap: wrap; }
|
.card-bar { align-items: flex-start; flex-wrap: wrap; }
|
||||||
.export-button { margin-left: auto; }
|
.cart-button { margin-left: auto; }
|
||||||
|
.cart-bar { left: 12px; right: 12px; transform: none; flex-wrap: wrap; }
|
||||||
|
.cart-chips { max-width: none; }
|
||||||
.chart { min-width: 560px; }
|
.chart { min-width: 560px; }
|
||||||
.card { overflow-x: auto; }
|
.card { overflow-x: auto; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
period,retail,robot_store,own_app,tiktok,amazon,online_other,wholesale,total_revenue
|
||||||
|
2024H1,0.9,0.1,0.3,0.1,0.0,0.2,0.2,1.83
|
||||||
|
2024H2,2.5,0.3,1.6,0.9,0.0,0.5,0.4,6.19
|
||||||
|
2024FY,3.4,0.4,1.9,1.1,0.0,0.7,0.6,8.02
|
||||||
|
2025H1,7.4,1.1,8.9,3.1,0.0,1.3,1.0,22.65
|
||||||
|
2025H2,12.7,1.3,19.8,7.3,0.0,3.2,1.2,45.42
|
||||||
|
2025FY,20.0,2.3,28.7,10.4,0.0,4.4,2.2,68.07
|
||||||
|
2026H1,9.0,1.0,4.9,0.0,1.7,0.6,1.6,18.91
|
||||||
|
@@ -0,0 +1,3 @@
|
|||||||
|
period,retail,robot_store,own_app,tiktok,amazon,online_other,wholesale,total_revenue
|
||||||
|
2024FY,3.4,0.4,1.9,1.1,0.0,0.7,0.6,8.02
|
||||||
|
2025FY,20.0,2.3,28.7,10.4,0.0,4.4,2.2,68.07
|
||||||
|
@@ -0,0 +1,6 @@
|
|||||||
|
period,retail,robot_store,own_app,tiktok,amazon,online_other,wholesale,total_revenue
|
||||||
|
2024H1,0.9,0.1,0.3,0.1,0.0,0.2,0.2,1.83
|
||||||
|
2024H2,2.5,0.3,1.6,0.9,0.0,0.5,0.4,6.19
|
||||||
|
2025H1,7.4,1.1,8.9,3.1,0.0,1.3,1.0,22.65
|
||||||
|
2025H2,12.7,1.3,19.8,7.3,0.0,3.2,1.2,45.42
|
||||||
|
2026H1,9.0,1.0,4.9,0.0,1.7,0.6,1.6,18.91
|
||||||
|
@@ -0,0 +1,8 @@
|
|||||||
|
period,retail,robot_store,own_app,shopee,online_other,wholesale,total_revenue
|
||||||
|
2024H1,4.8,0.2,0.4,0.4,0.8,1.3,7.97
|
||||||
|
2024H2,11.8,0.2,2.6,2.4,3.0,3.0,23.13
|
||||||
|
2024FY,16.7,0.4,3.0,2.9,3.8,4.3,31.10
|
||||||
|
2025H1,14.9,0.3,3.1,2.8,4.8,2.6,28.51
|
||||||
|
2025H2,28.3,0.6,11.4,2.9,5.5,2.9,51.60
|
||||||
|
2025FY,43.2,0.9,14.6,5.7,10.3,5.4,80.11
|
||||||
|
2026H1,17.2,0.6,3.4,1.1,2.0,1.6,25.76
|
||||||
|
@@ -0,0 +1,3 @@
|
|||||||
|
period,retail,robot_store,own_app,shopee,online_other,wholesale,total_revenue
|
||||||
|
2024FY,16.7,0.4,3.0,2.9,3.8,4.3,31.10
|
||||||
|
2025FY,43.2,0.9,14.6,5.7,10.3,5.4,80.11
|
||||||
|
@@ -0,0 +1,6 @@
|
|||||||
|
period,retail,robot_store,own_app,shopee,online_other,wholesale,total_revenue
|
||||||
|
2024H1,4.8,0.2,0.4,0.4,0.8,1.3,7.97
|
||||||
|
2024H2,11.8,0.2,2.6,2.4,3.0,3.0,23.13
|
||||||
|
2025H1,14.9,0.3,3.1,2.8,4.8,2.6,28.51
|
||||||
|
2025H2,28.3,0.6,11.4,2.9,5.5,2.9,51.60
|
||||||
|
2026H1,17.2,0.6,3.4,1.1,2.0,1.6,25.76
|
||||||
|
@@ -0,0 +1,8 @@
|
|||||||
|
period,retail,robot_store,roboshop,tmall,douyin,online_other,wholesale,total_revenue
|
||||||
|
2024H1,20.0,3.4,4.0,2.0,2.1,1.3,2.4,35.22
|
||||||
|
2024H2,26.0,4.1,7.2,4.3,3.9,3.0,5.2,53.65
|
||||||
|
2024FY,46.0,7.5,11.1,6.3,6.0,4.3,7.6,88.87
|
||||||
|
2025H1,44.1,6.8,11.3,6.6,5.6,5.9,2.6,82.82
|
||||||
|
2025H2,56.7,6.7,23.0,11.3,10.3,11.3,6.5,125.69
|
||||||
|
2025FY,100.8,13.5,34.2,17.9,15.9,17.2,9.1,208.51
|
||||||
|
2026H1,60.9,7.8,20.6,9.0,9.8,8.4,5.5,122.00
|
||||||
|
@@ -0,0 +1,3 @@
|
|||||||
|
period,retail,robot_store,roboshop,tmall,douyin,online_other,wholesale,total_revenue
|
||||||
|
2024FY,46.0,7.5,11.1,6.3,6.0,4.3,7.6,88.87
|
||||||
|
2025FY,100.8,13.5,34.2,17.9,15.9,17.2,9.1,208.51
|
||||||
|
@@ -0,0 +1,6 @@
|
|||||||
|
period,retail,robot_store,roboshop,tmall,douyin,online_other,wholesale,total_revenue
|
||||||
|
2024H1,20.0,3.4,4.0,2.0,2.1,1.3,2.4,35.22
|
||||||
|
2024H2,26.0,4.1,7.2,4.3,3.9,3.0,5.2,53.65
|
||||||
|
2025H1,44.1,6.8,11.3,6.6,5.6,5.9,2.6,82.82
|
||||||
|
2025H2,56.7,6.7,23.0,11.3,10.3,11.3,6.5,125.69
|
||||||
|
2026H1,60.9,7.8,20.6,9.0,9.8,8.4,5.5,122.00
|
||||||
|
@@ -0,0 +1,8 @@
|
|||||||
|
period,retail,robot_store,own_app,online_other,wholesale,total_revenue
|
||||||
|
2024H1,0.4,0.0,0.0,0.1,0.1,0.58
|
||||||
|
2024H2,1.2,0.0,0.2,0.2,0.2,1.81
|
||||||
|
2024FY,1.6,0.0,0.2,0.3,0.3,2.39
|
||||||
|
2025H1,2.7,0.0,0.8,0.8,0.4,4.77
|
||||||
|
2025H2,5.8,0.1,2.3,1.1,0.4,9.74
|
||||||
|
2025FY,8.6,0.2,3.2,1.9,0.7,14.51
|
||||||
|
2026H1,4.0,0.1,0.5,0.1,0.2,5.06
|
||||||
|
@@ -0,0 +1,3 @@
|
|||||||
|
period,retail,robot_store,own_app,online_other,wholesale,total_revenue
|
||||||
|
2024FY,1.6,0.0,0.2,0.3,0.3,2.39
|
||||||
|
2025FY,8.6,0.2,3.2,1.9,0.7,14.51
|
||||||
|
@@ -0,0 +1,6 @@
|
|||||||
|
period,retail,robot_store,own_app,online_other,wholesale,total_revenue
|
||||||
|
2024H1,0.4,0.0,0.0,0.1,0.1,0.58
|
||||||
|
2024H2,1.2,0.0,0.2,0.2,0.2,1.81
|
||||||
|
2025H1,2.7,0.0,0.8,0.8,0.4,4.77
|
||||||
|
2025H2,5.8,0.1,2.3,1.1,0.4,9.74
|
||||||
|
2026H1,4.0,0.1,0.5,0.1,0.2,5.06
|
||||||
|
@@ -168,7 +168,7 @@ export function brkHoldingsChart(rows, { width, height }) {
|
|||||||
})),
|
})),
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('绿色=相对上一年新增;红色=相对上一年清仓;蓝色深浅=相对持股数量 | @思考的Joey'),
|
footnote('绿色=相对上一年新增;红色=相对上一年清仓;蓝色深浅=相对持股数量 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -72,7 +72,7 @@ export function growthChart(rows, { width, height }) {
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('示例数据 | @思考的Joey'),
|
footnote('示例数据 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -26,7 +26,7 @@ import { workingCapitalDaysChart } from './ppmt_working_capital_days.js';
|
|||||||
import { expenseStructureChart } from './ppmt_expense_structure.js';
|
import { expenseStructureChart } from './ppmt_expense_structure.js';
|
||||||
import { channelRevenueChart } from './ppmt_channel_revenue.js';
|
import { channelRevenueChart } from './ppmt_channel_revenue.js';
|
||||||
import { channelShareChart } from './ppmt_channel_share.js';
|
import { channelShareChart } from './ppmt_channel_share.js';
|
||||||
import { makeRegionChannelShareChart } from './ppmt_channel_share_region.js';
|
import { makeRegionChannelShareChart, makeRegionChannelAmountChart } from './ppmt_channel_share_region.js';
|
||||||
import { storesRetailChart } from './ppmt_stores_retail.js';
|
import { storesRetailChart } from './ppmt_stores_retail.js';
|
||||||
import { storesRobotChart } from './ppmt_stores_robot.js';
|
import { storesRobotChart } from './ppmt_stores_robot.js';
|
||||||
import { regionShareChart } from './ppmt_region_share.js';
|
import { regionShareChart } from './ppmt_region_share.js';
|
||||||
@@ -70,6 +70,10 @@ export const chartRegistry = {
|
|||||||
'ppmt-channel-share-apac': makeRegionChannelShareChart('apac'),
|
'ppmt-channel-share-apac': makeRegionChannelShareChart('apac'),
|
||||||
'ppmt-channel-share-americas': makeRegionChannelShareChart('americas'),
|
'ppmt-channel-share-americas': makeRegionChannelShareChart('americas'),
|
||||||
'ppmt-channel-share-europe': makeRegionChannelShareChart('europe'),
|
'ppmt-channel-share-europe': makeRegionChannelShareChart('europe'),
|
||||||
|
'ppmt-channel-amount-china': makeRegionChannelAmountChart('china'),
|
||||||
|
'ppmt-channel-amount-apac': makeRegionChannelAmountChart('apac'),
|
||||||
|
'ppmt-channel-amount-americas': makeRegionChannelAmountChart('americas'),
|
||||||
|
'ppmt-channel-amount-europe': makeRegionChannelAmountChart('europe'),
|
||||||
'ppmt-stores-retail': storesRetailChart,
|
'ppmt-stores-retail': storesRetailChart,
|
||||||
'ppmt-stores-robot': storesRobotChart,
|
'ppmt-stores-robot': storesRobotChart,
|
||||||
'ppmt-region-share': regionShareChart,
|
'ppmt-region-share': regionShareChart,
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function inventoryChart(rows, { width, height }) {
|
|||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote(
|
footnote(
|
||||||
'周转天数=平均存货÷当期销售成本×期间天数(半年181/年度365,期初=上一年末,日历天数口径);' +
|
'周转天数=平均存货÷当期销售成本×期间天数(半年181/年度365,期初=上一年末,日历天数口径);' +
|
||||||
'与公司披露一致:23年133/24年102/25年123/26H1 201天 | @思考的Joey'
|
'与公司披露一致:23年133/24年102/25年123/26H1 201天 | @Joey知行记'
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -74,7 +74,7 @@ export function marginChart(rows, { width, height }) {
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('示例数据 | @思考的Joey'),
|
footnote('示例数据 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function assetsChart(rows, { width, height, view = 'all' }) {
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特财报 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特财报 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function ppmtBridgeChart(rows, { width, height }) {
|
|||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote(
|
footnote(
|
||||||
'各项为占营收百分点,已折算为对净利率影响(×税盾系数)\n' +
|
'各项为占营收百分点,已折算为对净利率影响(×税盾系数)\n' +
|
||||||
'其他收益净额:26H1净损失6.89亿,主要为汇兑损失7.20亿(25H1为汇兑收益1.20亿) | @思考的Joey'
|
'其他收益净额:26H1净损失6.89亿,主要为汇兑损失7.20亿(25H1为汇兑收益1.20亿) | @Joey知行记'
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function cashConversionChart(rows, { width, height, view = 'all' }) {
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特合并现金流量表、利润表;H2为单独半年 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特合并现金流量表、利润表;H2为单独半年 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function cashflowCompositionChart(rows, { width, height, view = 'all' })
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特合并现金流量表;H2为单独半年 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特合并现金流量表;H2为单独半年 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function categoryChart(rows, { width, height, view = 'all' }) {
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特财报 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特财报 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export function channelRevenueChart(rows, { width, height, view = 'all' }) {
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特营收构成表;渠道口径按财报披露 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特营收构成表;渠道口径按财报披露 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function channelShareChart(rows, { width, height, view = 'all' }) {
|
|||||||
})),
|
})),
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特分地区渠道收入披露(中国含港澳台);H2为单半年(全年−H1)\n线上渠道含抽盒机、天猫、抖音、自研APP及官网等 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特分地区渠道收入披露(中国含港澳台);H2为单半年(全年−H1)\n线上渠道含抽盒机、天猫、抖音、自研APP及官网等 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,28 +28,111 @@ const REGIONS = {
|
|||||||
title: '中国区',
|
title: '中国区',
|
||||||
source: '中国(含港澳台)',
|
source: '中国(含港澳台)',
|
||||||
series: ['retail', 'robot_store', 'roboshop', 'tmall', 'douyin', 'online_other', 'wholesale'],
|
series: ['retail', 'robot_store', 'roboshop', 'tmall', 'douyin', 'online_other', 'wholesale'],
|
||||||
footnote: '数据来源:泡泡玛特中国区(含港澳台)分渠道收入披露;H2为单半年(全年−H1)\n线上渠道按财报披露拆分为抽盒机、天猫旗舰店、抖音平台与其他线上 | @思考的Joey',
|
footnote: '数据来源:泡泡玛特中国区(含港澳台)分渠道收入披露;H2为单半年(全年−H1)\n线上渠道按财报披露拆分为抽盒机、天猫旗舰店、抖音平台与其他线上 | @Joey知行记',
|
||||||
},
|
},
|
||||||
apac: {
|
apac: {
|
||||||
title: '亚太区',
|
title: '亚太区',
|
||||||
source: '亚太区',
|
source: '亚太区',
|
||||||
series: ['retail', 'robot_store', 'own_app', 'shopee', 'online_other', 'wholesale'],
|
series: ['retail', 'robot_store', 'own_app', 'shopee', 'online_other', 'wholesale'],
|
||||||
footnote: '数据来源:泡泡玛特亚太区分渠道收入披露;H2为单半年(全年−H1)\n线上渠道按财报披露拆分为自研APP及官网、Shopee与其他线上 | @思考的Joey',
|
footnote: '数据来源:泡泡玛特亚太区分渠道收入披露;H2为单半年(全年−H1)\n线上渠道按财报披露拆分为自研APP及官网、Shopee与其他线上 | @Joey知行记',
|
||||||
},
|
},
|
||||||
americas: {
|
americas: {
|
||||||
title: '美洲区',
|
title: '美洲区',
|
||||||
source: '美洲区',
|
source: '美洲区',
|
||||||
series: ['retail', 'robot_store', 'own_app', 'tiktok', 'amazon', 'online_other', 'wholesale'],
|
series: ['retail', 'robot_store', 'own_app', 'tiktok', 'amazon', 'online_other', 'wholesale'],
|
||||||
footnote: '数据来源:泡泡玛特美洲区分渠道收入披露;H2为单半年(全年−H1)\n2026H1起TikTok无收入,亚马逊为2026年新开渠道 | @思考的Joey',
|
footnote: '数据来源:泡泡玛特美洲区分渠道收入披露;H2为单半年(全年−H1)\n2026H1起TikTok无收入,亚马逊为2026年新开渠道 | @Joey知行记',
|
||||||
},
|
},
|
||||||
europe: {
|
europe: {
|
||||||
title: '欧洲及其他区',
|
title: '欧洲及其他区',
|
||||||
source: '欧洲及其他地区',
|
source: '欧洲及其他地区',
|
||||||
series: ['retail', 'robot_store', 'own_app', 'online_other', 'wholesale'],
|
series: ['retail', 'robot_store', 'own_app', 'online_other', 'wholesale'],
|
||||||
footnote: '数据来源:泡泡玛特欧洲及其他地区分渠道收入披露;H2为单半年(全年−H1)\n线上渠道按财报披露拆分为自研APP及官网与其他线上 | @思考的Joey',
|
footnote: '数据来源:泡泡玛特欧洲及其他地区分渠道收入披露;H2为单半年(全年−H1)\n线上渠道按财报披露拆分为自研APP及官网与其他线上 | @Joey知行记',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 轴上限取整齐刻度,柱高比例确定后,段标签的段内/外置可按像素判断(同 ppmt_channel_revenue)。 */
|
||||||
|
const AMOUNT_NICE_MAX = [10, 20, 25, 50, 60, 80, 100, 120, 150, 200, 250, 300, 400, 500];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分地区渠道收入金额堆叠柱:与占比图同源同色,直接使用披露金额(亿元),
|
||||||
|
* 不做占比×总收入还原。矮段标签外置到柱体右侧的机制同全局渠道金额图。
|
||||||
|
*/
|
||||||
|
export function makeRegionChannelAmountChart(regionKey) {
|
||||||
|
const region = REGIONS[regionKey];
|
||||||
|
if (!region) throw new Error(`未配置的地区渠道金额图:${regionKey}`);
|
||||||
|
return function regionChannelAmountChart(rows, { width, height, view = 'all' }) {
|
||||||
|
const periods = col(rows, 'period');
|
||||||
|
const barWidth = view === 'all' ? 48 : 80;
|
||||||
|
const totalMax = Math.max(...rows.map((row) => row.total_revenue));
|
||||||
|
const axisMax = AMOUNT_NICE_MAX.find((max) => max >= totalMax * 1.02)
|
||||||
|
?? Math.ceil((totalMax * 1.2) / 100) * 100;
|
||||||
|
// 绘图区像素高:画布高 − grid top 145 − grid bottom 100(两行脚注)
|
||||||
|
const pxPerUnit = (height - LAYOUT.gridTop - 100) / axisMax;
|
||||||
|
const rowOffsets = fanTinyLabelOffsets(rows, region.series, pxPerUnit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
title: {
|
||||||
|
...titleStyle,
|
||||||
|
text: `泡泡玛特${region.title}渠道收入金额(${financialViewLabel(view)})`,
|
||||||
|
subtext: '堆叠柱为各渠道收入(亿元);矮段数值标注在柱体右侧,线上渠道按披露细分拆分',
|
||||||
|
},
|
||||||
|
legend: standardLegend(region.series.map((key) => SERIES[key].name)),
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
textStyle: { fontFamily: FONT },
|
||||||
|
formatter: (params) => {
|
||||||
|
const row = rows[periods.indexOf(params[0].axisValue)];
|
||||||
|
return `<b>${params[0].axisValue}</b> ${region.source}总收入 ${row.total_revenue.toFixed(2)} 亿元<br/>${params
|
||||||
|
.filter((item) => item.value !== null)
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((item) => `${item.marker}${item.seriesName}: ${item.value.toFixed(1)} 亿元`)
|
||||||
|
.join('<br/>')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 100 },
|
||||||
|
xAxis: { ...standalonePeriodAxis, data: periods },
|
||||||
|
yAxis: {
|
||||||
|
...axisCommon,
|
||||||
|
type: 'value',
|
||||||
|
name: '渠道收入(亿元)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 56,
|
||||||
|
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||||
|
min: 0,
|
||||||
|
max: axisMax,
|
||||||
|
interval: axisMax % 4 === 0 ? axisMax / 4 : axisMax / 5,
|
||||||
|
},
|
||||||
|
series: region.series.map((key, ci) => barSeries({
|
||||||
|
name: SERIES[key].name,
|
||||||
|
data: rows.map((row, ri) => stackedSharePoint(row[key], {
|
||||||
|
pxPerUnit,
|
||||||
|
labelColor: SERIES[key].labelColor,
|
||||||
|
dy: rowOffsets[ri][ci] ?? 0,
|
||||||
|
})),
|
||||||
|
color: SERIES[key].color,
|
||||||
|
barWidth,
|
||||||
|
stack: `channel-amount-${regionKey}`,
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'inside',
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontWeight: 500,
|
||||||
|
overflow: 'none',
|
||||||
|
formatter: (point) => point.value.toFixed(1),
|
||||||
|
},
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
})),
|
||||||
|
graphic: [
|
||||||
|
...watermarks(width, height),
|
||||||
|
footnote(region.footnote),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
/** 分地区渠道收入占比:线上渠道拆分披露的细分平台,每根柱按当期该地区总收入归一化。 */
|
/** 分地区渠道收入占比:线上渠道拆分披露的细分平台,每根柱按当期该地区总收入归一化。 */
|
||||||
export function makeRegionChannelShareChart(regionKey) {
|
export function makeRegionChannelShareChart(regionKey) {
|
||||||
const region = REGIONS[regionKey];
|
const region = REGIONS[regionKey];
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function ppmtCoreMarginChart(rows, { width, height, view = 'all' }) {
|
|||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote(
|
footnote(
|
||||||
'核心利润率由绝对值口径直接计算(核心利润÷营收),比三个比率行相减更准\n' +
|
'核心利润率由绝对值口径直接计算(核心利润÷营收),比三个比率行相减更准\n' +
|
||||||
'——后者各只存4位小数,累加误差可达0.045pct | @思考的Joey'
|
'——后者各只存4位小数,累加误差可达0.045pct | @Joey知行记'
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function ppmtCoreProfitChart(rows, { width, height, view = 'all' }) {
|
|||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote(
|
footnote(
|
||||||
'核心利润反映主业盈利能力(扣除营销与管理成本);' +
|
'核心利润反映主业盈利能力(扣除营销与管理成本);' +
|
||||||
'2025H1/H2同比增速受益于费用率优化和规模效应 | @思考的Joey'
|
'2025H1/H2同比增速受益于费用率优化和规模效应 | @Joey知行记'
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function expenseStructureChart(rows, { width, height, view = 'all' }) {
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特合并利润表;H2为单独半年 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特合并利润表;H2为单独半年 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function ppmtGrowthChart(rows, { width, height, view = 'all' }) {
|
|||||||
})),
|
})),
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特财报;2020H1/H2非GAAP净利用归母净利替代 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特财报;2020H1/H2非GAAP净利用归母净利替代 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function inventoryBalanceChart(rows, { width, height, view = 'all' }) {
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特历年合并资产负债表;存货为报告期末时点数 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特历年合并资产负债表;存货为报告期末时点数 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function ipChart(rows, { width, height, view = 'all' }) {
|
|||||||
series,
|
series,
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特财报 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特财报 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export function ppmtIpExMonstersChart(rows, { width, height, view = 'all' }) {
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('除 THE MONSTERS 外其余 IP 合计;同比对去年同半年 | @思考的Joey'),
|
footnote('除 THE MONSTERS 外其余 IP 合计;同比对去年同半年 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function ppmtIpShareChart(rows, { width, height }) {
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('HACIPUPU、BUNNY、PUCKY 等 IP 2026H1 无收入数据,未列入 | @思考的Joey'),
|
footnote('HACIPUPU、BUNNY、PUCKY 等 IP 2026H1 无收入数据,未列入 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function ppmtIpShareHistoryChart(rows, { width, height, view = 'all' }) {
|
|||||||
})),
|
})),
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('其他IP(含已停单列)= Zsiga、HACIPUPU、BUNNY、PUCKY、小甜豆等 | @思考的Joey'),
|
footnote('其他IP(含已停单列)= Zsiga、HACIPUPU、BUNNY、PUCKY、小甜豆等 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function liquidityWorkingCapitalChart(rows, { width, height, view = 'all'
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特合并资产负债表 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特合并资产负债表 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function ppmtMarginsChart(rows, { width, height, view = 'all' }) {
|
|||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote(
|
footnote(
|
||||||
'毛利率/费用率取自利润表;非国际归母净利率=非GAAP净利÷营业收入,' +
|
'毛利率/费用率取自利润表;非国际归母净利率=非GAAP净利÷营业收入,' +
|
||||||
'2020H1/H2非GAAP缺失用归母净利替代 | @思考的Joey'
|
'2020H1/H2非GAAP缺失用归母净利替代 | @Joey知行记'
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function profitChart(rows, { width, height, view = 'all' }) {
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特财报;H2为单独半年 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特财报;H2为单独半年 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function regionChannelChart(rows, { width, height }) {
|
|||||||
})),
|
})),
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特财报 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特财报 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function regionChannelYoyTimeseriesChart(rows, { width, height }) {
|
|||||||
}],
|
}],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特财报;早期海外为合并口径 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特财报;早期海外为合并口径 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function regionShareChart(rows, { width, height, view = 'all' }) {
|
|||||||
})),
|
})),
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特财报;中国含港澳台\n2024H1为按2025年中报披露的分地区同比还原;H2为单半年 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特财报;中国含港澳台\n2024H1为按2025年中报披露的分地区同比还原;H2为单半年 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function regionChannelYoyChart(rows, { width, height }) {
|
|||||||
})),
|
})),
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特财报(2026H1 vs 2025H1同比)| @思考的Joey'),
|
footnote('数据来源:泡泡玛特财报(2026H1 vs 2025H1同比)| @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function retailStoreRevenueChart(rows, { width, height, view = 'all' }) {
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特历年公告;收入仅含“零售店”渠道;H2收入=全年−H1\n2024年海外各区域缺少期初店数,相关单店收入留空 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特历年公告;收入仅含“零售店”渠道;H2收入=全年−H1\n2024年海外各区域缺少期初店数,相关单店收入留空 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function robotStoreRevenueChart(rows, { width, height, view = 'all' }) {
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特历年公告;海外收入=亚太+美洲+欧洲及其他的机器人商店收入;H2收入=全年−H1\n2024H1海外期末机器人商店数未披露,相关单店收入留空 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特历年公告;海外收入=亚太+美洲+欧洲及其他的机器人商店收入;H2收入=全年−H1\n2024H1海外期末机器人商店数未披露,相关单店收入留空 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export function sankeyChart(rows, { width, height }) {
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特财报 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特财报 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function storesRetailChart(rows, { width, height, view = 'all' }) {
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特历年公告;门店数为各期末时点数\n2023年及以前海外合计含港澳台;2024年起港澳台单列,海外=亚太+美洲+欧洲 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特历年公告;门店数为各期末时点数\n2023年及以前海外合计含港澳台;2024年起港澳台单列,海外=亚太+美洲+欧洲 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function storesRetailShareChart(rows, { width, height, view = 'all' }) {
|
|||||||
})),
|
})),
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特历年公告;门店数为各报告期末时点数\n中国=中国内地+港澳台;其他=全球合计−中国−亚太−美洲−欧洲 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特历年公告;门店数为各报告期末时点数\n中国=中国内地+港澳台;其他=全球合计−中国−亚太−美洲−欧洲 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,8 +135,8 @@ export function storesRobotChart(rows, { width, height, view = 'all' }) {
|
|||||||
[
|
[
|
||||||
'数据来源:泡泡玛特历年公告;门店数为各期末时点数',
|
'数据来源:泡泡玛特历年公告;门店数为各期末时点数',
|
||||||
view === 'annual'
|
view === 'annual'
|
||||||
? '海外合计2023年及以前含港澳台、其后不含 | @思考的Joey'
|
? '海外合计2023年及以前含港澳台、其后不含 | @Joey知行记'
|
||||||
: '海外合计2023年及以前含港澳台、其后不含;2024H1海外数量未披露 | @思考的Joey',
|
: '海外合计2023年及以前含港澳台、其后不含;2024H1海外数量未披露 | @Joey知行记',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function workingCapitalDaysChart(rows, { width, height }) {
|
|||||||
})),
|
})),
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('数据来源:泡泡玛特财务指标(合并报表);原始180/360天口径已换算为181/365天 | @思考的Joey'),
|
footnote('数据来源:泡泡玛特财务指标(合并报表);原始180/360天口径已换算为181/365天 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function twoLineChart(rows, { width, height }) {
|
|||||||
],
|
],
|
||||||
graphic: [
|
graphic: [
|
||||||
...watermarks(width, height),
|
...watermarks(width, height),
|
||||||
footnote('示例数据 | @思考的Joey'),
|
footnote('示例数据 | @Joey知行记'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+121
@@ -1,17 +1,25 @@
|
|||||||
import { loadCSV } from './csv.js';
|
import { loadCSV } from './csv.js';
|
||||||
import { getChartBuilder } from './charts/index.js';
|
import { getChartBuilder } from './charts/index.js';
|
||||||
|
import { downloadCanvas, mergeImages } from './merge.js';
|
||||||
import {
|
import {
|
||||||
createAppShell,
|
createAppShell,
|
||||||
renderAnchors,
|
renderAnchors,
|
||||||
renderCards,
|
renderCards,
|
||||||
|
renderCartBar,
|
||||||
renderCompanies,
|
renderCompanies,
|
||||||
renderTabs,
|
renderTabs,
|
||||||
showChartError,
|
showChartError,
|
||||||
|
showMergePreview,
|
||||||
} from './ui.js';
|
} from './ui.js';
|
||||||
|
|
||||||
const instances = new Map();
|
const instances = new Map();
|
||||||
const activeViews = new Map();
|
const activeViews = new Map();
|
||||||
const chartRenderVersions = new Map();
|
const chartRenderVersions = new Map();
|
||||||
|
// 合并队列:instanceId → { instanceId, title, filename, dataUrl }
|
||||||
|
// dataUrl 是加购(及后续每次重渲染)时的快照,切换 tab 后实例被 dispose 也能照常导出
|
||||||
|
const cart = new Map();
|
||||||
|
let cartMode = 'pairs';
|
||||||
|
let closePreview;
|
||||||
let manifest;
|
let manifest;
|
||||||
let shell;
|
let shell;
|
||||||
let state;
|
let state;
|
||||||
@@ -123,6 +131,7 @@ async function renderChart(chart, version, chartVersion) {
|
|||||||
instances.set(chart.instanceId, instance);
|
instances.set(chart.instanceId, instance);
|
||||||
const { width, height } = el.getBoundingClientRect();
|
const { width, height } = el.getBoundingClientRect();
|
||||||
instance.setOption(getChartBuilder(chart.key)(rows, { width, height, view }), true);
|
instance.setOption(getChartBuilder(chart.key)(rows, { width, height, view }), true);
|
||||||
|
captureSnapshot(chart);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (version === renderVersion) showChartError(chart.instanceId, error.message);
|
if (version === renderVersion) showChartError(chart.instanceId, error.message);
|
||||||
console.error(`渲染图表失败:${chart.id}`, error);
|
console.error(`渲染图表失败:${chart.id}`, error);
|
||||||
@@ -157,6 +166,116 @@ function exportPNG(chartId, filename) {
|
|||||||
anchor.click();
|
anchor.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 图表在合并队列中时,每次重渲染(切换视图、重载数据、resize)都刷新快照,保证所见即所得 */
|
||||||
|
function captureSnapshot(chart) {
|
||||||
|
if (!cart.has(chart.instanceId)) return;
|
||||||
|
const instance = instances.get(chart.instanceId);
|
||||||
|
if (!instance) return;
|
||||||
|
cart.get(chart.instanceId).dataUrl = instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCart(chart) {
|
||||||
|
if (cart.delete(chart.instanceId)) {
|
||||||
|
updateCartButton(chart.instanceId);
|
||||||
|
} else {
|
||||||
|
const instance = instances.get(chart.instanceId);
|
||||||
|
if (!instance) return;
|
||||||
|
cart.set(chart.instanceId, {
|
||||||
|
instanceId: chart.instanceId,
|
||||||
|
title: chart.title,
|
||||||
|
filename: chart.filename || chart.title,
|
||||||
|
dataUrl: instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' }),
|
||||||
|
});
|
||||||
|
updateCartButton(chart.instanceId);
|
||||||
|
}
|
||||||
|
renderCart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCartButton(instanceId) {
|
||||||
|
const btn = document.querySelector(`[data-cart-id="${instanceId}"]`);
|
||||||
|
if (!btn) return;
|
||||||
|
const inCart = cart.has(instanceId);
|
||||||
|
btn.dataset.inCart = String(inCart);
|
||||||
|
btn.textContent = inCart ? '已加入 ✓' : '➕ 加入合并';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map 保持插入序,删除后按新顺序重建即完成上移/下移 */
|
||||||
|
function moveCartItem(instanceId, delta) {
|
||||||
|
const entries = [...cart.entries()];
|
||||||
|
const from = entries.findIndex(([id]) => id === instanceId);
|
||||||
|
const to = from + delta;
|
||||||
|
if (from < 0 || to < 0 || to >= entries.length) return;
|
||||||
|
[entries[from], entries[to]] = [entries[to], entries[from]];
|
||||||
|
cart.clear();
|
||||||
|
entries.forEach(([id, item]) => cart.set(id, item));
|
||||||
|
renderCart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipName(name, max = 24) {
|
||||||
|
return name.length > max ? `${name.slice(0, max)}…` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按当前模式把队列合成为一组 { canvas, filename, partial? }:pairs 两两合并,single 全部拼一张 */
|
||||||
|
async function buildCompositions() {
|
||||||
|
const items = [...cart.values()];
|
||||||
|
if (!items.length) return [];
|
||||||
|
if (cartMode === 'single') {
|
||||||
|
return [{
|
||||||
|
canvas: await mergeImages(items.map((item) => item.dataUrl)),
|
||||||
|
filename: `${clipName(items[0].filename)}等${items.length}张合并`,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
const pairs = [];
|
||||||
|
for (let i = 0; i < items.length; i += 2) {
|
||||||
|
const pair = items.slice(i, i + 2);
|
||||||
|
pairs.push({
|
||||||
|
canvas: await mergeImages(pair.map((item) => item.dataUrl)),
|
||||||
|
filename: pair.map((item) => clipName(item.filename)).join('+'),
|
||||||
|
partial: pair.length === 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMergePreview() {
|
||||||
|
const entries = await buildCompositions();
|
||||||
|
if (!entries.length) return;
|
||||||
|
const notice = entries.some((entry) => entry.partial) ? '队列为奇数张,最后一张单独导出' : '';
|
||||||
|
const download = (entry) => downloadCanvas(entry.canvas, entry.filename);
|
||||||
|
closePreview = showMergePreview(entries, {
|
||||||
|
notice,
|
||||||
|
onDownload: download,
|
||||||
|
onDownloadAll: () => entries.forEach((entry, index) => {
|
||||||
|
setTimeout(() => download(entry), index * 350);
|
||||||
|
}),
|
||||||
|
onClose: () => closePreview?.(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCart() {
|
||||||
|
renderCartBar(shell.cartBar, {
|
||||||
|
items: [...cart.values()],
|
||||||
|
mode: cartMode,
|
||||||
|
onModeChange: (mode) => {
|
||||||
|
cartMode = mode;
|
||||||
|
renderCart();
|
||||||
|
},
|
||||||
|
onMove: moveCartItem,
|
||||||
|
onRemove: (instanceId) => {
|
||||||
|
cart.delete(instanceId);
|
||||||
|
updateCartButton(instanceId);
|
||||||
|
renderCart();
|
||||||
|
},
|
||||||
|
onClear: () => {
|
||||||
|
const ids = [...cart.keys()];
|
||||||
|
cart.clear();
|
||||||
|
ids.forEach(updateCartButton);
|
||||||
|
renderCart();
|
||||||
|
},
|
||||||
|
onPreview: openMergePreview,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateHash(nextState) {
|
function updateHash(nextState) {
|
||||||
const hash = serializeHash(nextState);
|
const hash = serializeHash(nextState);
|
||||||
if (window.location.hash === hash) {
|
if (window.location.hash === hash) {
|
||||||
@@ -189,6 +308,8 @@ function renderCurrent() {
|
|||||||
});
|
});
|
||||||
renderCards(shell.cards, charts, activeViews, {
|
renderCards(shell.cards, charts, activeViews, {
|
||||||
onExport: exportPNG,
|
onExport: exportPNG,
|
||||||
|
isCarted: (instanceId) => cart.has(instanceId),
|
||||||
|
onToggleCart: toggleCart,
|
||||||
onSelectView: (chart, view) => {
|
onSelectView: (chart, view) => {
|
||||||
if (activeViews.get(chart.viewKey) === view) return;
|
if (activeViews.get(chart.viewKey) === view) return;
|
||||||
activeViews.set(chart.viewKey, view);
|
activeViews.set(chart.viewKey, view);
|
||||||
|
|||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* 图片纵向合成 —— 把多张图表 PNG 顺序拼接为一张竖版长图。
|
||||||
|
* 公众号文章在手机上以竖版窗口预览,横版单图显示过小,
|
||||||
|
* 两张上下拼接后(2400×~2800)接近满屏,阅读体验明显更好。
|
||||||
|
* 纯浏览器 Canvas API,无依赖。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULTS = { gap: 96, background: '#fff' }; // gap 为设备像素:@2x 导出下 96 = 视觉 48px
|
||||||
|
|
||||||
|
function loadImage(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error('图片解码失败'));
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按顺序纵向拼接一组图片 dataURL,返回合成后的 canvas。
|
||||||
|
* 宽度取各图最大值;宽度不一致的图等比缩放对齐(正常流程导出的图宽度一致,不会触发缩放)。
|
||||||
|
*/
|
||||||
|
export async function mergeImages(sources, options = {}) {
|
||||||
|
const { gap, background } = { ...DEFAULTS, ...options };
|
||||||
|
if (!sources.length) throw new Error('没有可合并的图片');
|
||||||
|
|
||||||
|
const images = await Promise.all(sources.map(loadImage));
|
||||||
|
const width = Math.max(...images.map((img) => img.width));
|
||||||
|
const heights = images.map((img) => Math.round(img.height * width / img.width));
|
||||||
|
const height = heights.reduce((sum, h) => sum + h, 0) + gap * (images.length - 1);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.fillStyle = background;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
let y = 0;
|
||||||
|
images.forEach((img, index) => {
|
||||||
|
ctx.drawImage(img, 0, y, width, heights[index]);
|
||||||
|
y += heights[index] + gap;
|
||||||
|
});
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** canvas → PNG Blob 触发下载(Blob URL 比超长 dataURL 下载更稳) */
|
||||||
|
export function downloadCanvas(canvas, filename) {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename.endsWith('.png') ? filename : `${filename}.png`;
|
||||||
|
anchor.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||||
|
}, 'image/png');
|
||||||
|
}
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
* 所有图表共用这一份配置,保证视觉一致性
|
* 所有图表共用这一份配置,保证视觉一致性
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const WATERMARK_TEXT = '@思考的Joey';
|
export const WATERMARK_TEXT = '@Joey知行记';
|
||||||
|
|
||||||
// 字体名用单引号:SVG 渲染时字符串会被塞进 style="..." 属性,
|
// 字体名用单引号:SVG 渲染时字符串会被塞进 style="..." 属性,
|
||||||
// 用双引号会提前闭合属性、生成非法 SVG(canvas 渲染看不出来,导出 SVG 时才炸)
|
// 用双引号会提前闭合属性、生成非法 SVG(canvas 渲染看不出来,导出 SVG 时才炸)
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ export function createAppShell(root, { onReload }) {
|
|||||||
content.append(tabs, cards);
|
content.append(tabs, cards);
|
||||||
const chartNav = element('aside', 'sidebar chart-sidebar');
|
const chartNav = element('aside', 'sidebar chart-sidebar');
|
||||||
layout.append(companyNav, content, chartNav);
|
layout.append(companyNav, content, chartNav);
|
||||||
root.append(layout, reloadButton);
|
const cartBar = element('div', 'cart-bar');
|
||||||
|
cartBar.hidden = true;
|
||||||
|
root.append(layout, reloadButton, cartBar);
|
||||||
|
|
||||||
return { companyNav, tabs, cards, chartNav };
|
return { companyNav, tabs, cards, chartNav, cartBar };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderCompanies(container, companies, activeCompanyId, onSelect) {
|
export function renderCompanies(container, companies, activeCompanyId, onSelect) {
|
||||||
@@ -57,7 +59,7 @@ export function renderTabs(container, tabs, activeTabId, onSelect) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderCards(container, charts, activeViews, { onExport, onSelectView }) {
|
export function renderCards(container, charts, activeViews, { onExport, isCarted, onToggleCart, onSelectView }) {
|
||||||
container.replaceChildren();
|
container.replaceChildren();
|
||||||
if (!charts.length) {
|
if (!charts.length) {
|
||||||
container.append(element('p', 'empty-state', '该分类暂时没有图表。'));
|
container.append(element('p', 'empty-state', '该分类暂时没有图表。'));
|
||||||
@@ -92,7 +94,13 @@ export function renderCards(container, charts, activeViews, { onExport, onSelect
|
|||||||
bar.append(switcher);
|
bar.append(switcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
bar.append(button('导出 PNG', {
|
const cartButton = button(isCarted?.(chart.instanceId) ? '已加入 ✓' : '➕ 加入合并', {
|
||||||
|
onClick: () => onToggleCart?.(chart),
|
||||||
|
className: 'cart-button',
|
||||||
|
});
|
||||||
|
cartButton.dataset.cartId = chart.instanceId;
|
||||||
|
cartButton.dataset.inCart = String(Boolean(isCarted?.(chart.instanceId)));
|
||||||
|
bar.append(cartButton, button('导出 PNG', {
|
||||||
onClick: () => onExport(chart.instanceId, chart.filename || chart.title),
|
onClick: () => onExport(chart.instanceId, chart.filename || chart.title),
|
||||||
className: 'export-button',
|
className: 'export-button',
|
||||||
}));
|
}));
|
||||||
@@ -121,3 +129,95 @@ export function showChartError(chartId, message) {
|
|||||||
if (!chartEl) return;
|
if (!chartEl) return;
|
||||||
chartEl.replaceChildren(element('div', 'chart-error', message));
|
chartEl.replaceChildren(element('div', 'chart-error', message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CART_MODES = [
|
||||||
|
['pairs', '两两合并'],
|
||||||
|
['single', '合并为一张'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function renderCartBar(container, { items = [], mode, onModeChange, onMove, onRemove, onClear, onPreview }) {
|
||||||
|
container.replaceChildren();
|
||||||
|
if (!items.length) {
|
||||||
|
container.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.hidden = false;
|
||||||
|
|
||||||
|
container.append(element('span', 'cart-label', `合并队列 ${items.length}`));
|
||||||
|
|
||||||
|
const chips = element('div', 'cart-chips');
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const chip = element('div', 'cart-chip');
|
||||||
|
chip.title = item.title;
|
||||||
|
const up = button('↑', { onClick: () => onMove?.(item.instanceId, -1), className: 'cart-chip-button' });
|
||||||
|
up.disabled = index === 0;
|
||||||
|
const down = button('↓', { onClick: () => onMove?.(item.instanceId, 1), className: 'cart-chip-button' });
|
||||||
|
down.disabled = index === items.length - 1;
|
||||||
|
chip.append(
|
||||||
|
element('span', 'cart-chip-name', item.title),
|
||||||
|
up,
|
||||||
|
down,
|
||||||
|
button('✕', { onClick: () => onRemove?.(item.instanceId), className: 'cart-chip-button' }),
|
||||||
|
);
|
||||||
|
chips.append(chip);
|
||||||
|
});
|
||||||
|
container.append(chips);
|
||||||
|
|
||||||
|
container.append(element('span', 'cart-divider'));
|
||||||
|
|
||||||
|
const modes = element('div', 'cart-modes');
|
||||||
|
CART_MODES.forEach(([value, label]) => {
|
||||||
|
modes.append(button(label, {
|
||||||
|
active: mode === value,
|
||||||
|
onClick: () => onModeChange?.(value),
|
||||||
|
className: 'cart-mode-button',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
container.append(modes);
|
||||||
|
|
||||||
|
container.append(button('预览导出', { onClick: () => onPreview?.(), className: 'cart-export-button' }));
|
||||||
|
container.append(button('清空', { onClick: () => onClear?.(), className: 'cart-clear-button' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showMergePreview(entries, { notice = '', onDownload, onDownloadAll, onClose }) {
|
||||||
|
const overlay = element('div', 'modal-overlay');
|
||||||
|
const modal = element('div', 'modal');
|
||||||
|
|
||||||
|
const head = element('div', 'modal-head');
|
||||||
|
head.append(element('h2', 'modal-title', `合并预览 · ${entries.length} 张`));
|
||||||
|
if (notice) head.append(element('span', 'modal-notice', notice));
|
||||||
|
head.append(button('✕', { onClick: onClose, className: 'modal-close' }));
|
||||||
|
|
||||||
|
const body = element('div', 'modal-body');
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const item = element('div', 'preview-item');
|
||||||
|
entry.canvas.className = 'preview-canvas';
|
||||||
|
item.append(entry.canvas);
|
||||||
|
item.append(button(`⬇ 下载:${entry.filename}.png`, {
|
||||||
|
onClick: () => onDownload?.(entry),
|
||||||
|
className: 'export-button',
|
||||||
|
}));
|
||||||
|
body.append(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const foot = element('div', 'modal-foot');
|
||||||
|
foot.append(button('全部下载', { onClick: () => onDownloadAll?.(), className: 'primary-button' }));
|
||||||
|
foot.append(button('取消', { onClick: onClose, className: 'export-button' }));
|
||||||
|
|
||||||
|
modal.append(head, body, foot);
|
||||||
|
overlay.append(modal);
|
||||||
|
overlay.addEventListener('click', (event) => {
|
||||||
|
if (event.target === overlay) onClose?.();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
document.body.append(overlay);
|
||||||
|
|
||||||
|
function onKeydown(event) {
|
||||||
|
if (event.key === 'Escape') onClose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKeydown);
|
||||||
|
overlay.remove();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+5
-1
@@ -32,6 +32,10 @@
|
|||||||
"ppmt-channel-share-apac": { "key": "ppmt-channel-share-apac", "title": "亚太区渠道收入占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_channel_share_apac.csv", "annual": "data/ppmt/ppmt_channel_share_apac_annual.csv", "halfyear": "data/ppmt/ppmt_channel_share_apac_halfyear.csv" }, "height": 680, "filename": "泡泡玛特亚太区渠道收入占比" },
|
"ppmt-channel-share-apac": { "key": "ppmt-channel-share-apac", "title": "亚太区渠道收入占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_channel_share_apac.csv", "annual": "data/ppmt/ppmt_channel_share_apac_annual.csv", "halfyear": "data/ppmt/ppmt_channel_share_apac_halfyear.csv" }, "height": 680, "filename": "泡泡玛特亚太区渠道收入占比" },
|
||||||
"ppmt-channel-share-americas": { "key": "ppmt-channel-share-americas", "title": "美洲区渠道收入占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_channel_share_americas.csv", "annual": "data/ppmt/ppmt_channel_share_americas_annual.csv", "halfyear": "data/ppmt/ppmt_channel_share_americas_halfyear.csv" }, "height": 680, "filename": "泡泡玛特美洲区渠道收入占比" },
|
"ppmt-channel-share-americas": { "key": "ppmt-channel-share-americas", "title": "美洲区渠道收入占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_channel_share_americas.csv", "annual": "data/ppmt/ppmt_channel_share_americas_annual.csv", "halfyear": "data/ppmt/ppmt_channel_share_americas_halfyear.csv" }, "height": 680, "filename": "泡泡玛特美洲区渠道收入占比" },
|
||||||
"ppmt-channel-share-europe": { "key": "ppmt-channel-share-europe", "title": "欧洲及其他区渠道收入占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_channel_share_europe.csv", "annual": "data/ppmt/ppmt_channel_share_europe_annual.csv", "halfyear": "data/ppmt/ppmt_channel_share_europe_halfyear.csv" }, "height": 680, "filename": "泡泡玛特欧洲及其他区渠道收入占比" },
|
"ppmt-channel-share-europe": { "key": "ppmt-channel-share-europe", "title": "欧洲及其他区渠道收入占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_channel_share_europe.csv", "annual": "data/ppmt/ppmt_channel_share_europe_annual.csv", "halfyear": "data/ppmt/ppmt_channel_share_europe_halfyear.csv" }, "height": 680, "filename": "泡泡玛特欧洲及其他区渠道收入占比" },
|
||||||
|
"ppmt-channel-amount-china": { "key": "ppmt-channel-amount-china", "title": "中国区渠道收入金额", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_channel_amount_china.csv", "annual": "data/ppmt/ppmt_channel_amount_china_annual.csv", "halfyear": "data/ppmt/ppmt_channel_amount_china_halfyear.csv" }, "height": 680, "filename": "泡泡玛特中国区渠道收入金额" },
|
||||||
|
"ppmt-channel-amount-apac": { "key": "ppmt-channel-amount-apac", "title": "亚太区渠道收入金额", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_channel_amount_apac.csv", "annual": "data/ppmt/ppmt_channel_amount_apac_annual.csv", "halfyear": "data/ppmt/ppmt_channel_amount_apac_halfyear.csv" }, "height": 680, "filename": "泡泡玛特亚太区渠道收入金额" },
|
||||||
|
"ppmt-channel-amount-americas": { "key": "ppmt-channel-amount-americas", "title": "美洲区渠道收入金额", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_channel_amount_americas.csv", "annual": "data/ppmt/ppmt_channel_amount_americas_annual.csv", "halfyear": "data/ppmt/ppmt_channel_amount_americas_halfyear.csv" }, "height": 680, "filename": "泡泡玛特美洲区渠道收入金额" },
|
||||||
|
"ppmt-channel-amount-europe": { "key": "ppmt-channel-amount-europe", "title": "欧洲及其他区渠道收入金额", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_channel_amount_europe.csv", "annual": "data/ppmt/ppmt_channel_amount_europe_annual.csv", "halfyear": "data/ppmt/ppmt_channel_amount_europe_halfyear.csv" }, "height": 680, "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-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-retail-share": { "key": "ppmt-stores-retail-share", "title": "分地区零售门店数量占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_stores_retail_share.csv", "annual": "data/ppmt/ppmt_stores_retail_share_annual.csv", "halfyear": "data/ppmt/ppmt_stores_retail_share_halfyear.csv" }, "height": 680, "filename": "泡泡玛特分地区零售门店数量占比" },
|
"ppmt-stores-retail-share": { "key": "ppmt-stores-retail-share", "title": "分地区零售门店数量占比", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_stores_retail_share.csv", "annual": "data/ppmt/ppmt_stores_retail_share_annual.csv", "halfyear": "data/ppmt/ppmt_stores_retail_share_halfyear.csv" }, "height": 680, "filename": "泡泡玛特分地区零售门店数量占比" },
|
||||||
"ppmt-retail-store-revenue": { "key": "ppmt-retail-store-revenue", "title": "各地区零售店单店收入", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_retail_store_revenue.csv", "annual": "data/ppmt/ppmt_retail_store_revenue_annual.csv", "halfyear": "data/ppmt/ppmt_retail_store_revenue_halfyear.csv" }, "height": 680, "filename": "泡泡玛特各地区零售店单店收入" },
|
"ppmt-retail-store-revenue": { "key": "ppmt-retail-store-revenue", "title": "各地区零售店单店收入", "defaultView": "all", "views": { "all": "data/ppmt/ppmt_retail_store_revenue.csv", "annual": "data/ppmt/ppmt_retail_store_revenue_annual.csv", "halfyear": "data/ppmt/ppmt_retail_store_revenue_halfyear.csv" }, "height": 680, "filename": "泡泡玛特各地区零售店单店收入" },
|
||||||
@@ -45,7 +49,7 @@
|
|||||||
{ "id": "expense", "name": "费用率", "chartRefs": ["ppmt-margins", "ppmt-expense-structure"] },
|
{ "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": "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": "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-share-apac", "ppmt-channel-share-americas", "ppmt-channel-share-europe", "ppmt-stores-retail", "ppmt-stores-retail-share", "ppmt-stores-robot", "ppmt-region-share"] },
|
{ "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": "store-revenue", "name": "单店收入", "chartRefs": ["ppmt-retail-store-revenue", "ppmt-robot-store-revenue"] }
|
{ "id": "store-revenue", "name": "单店收入", "chartRefs": ["ppmt-retail-store-revenue", "ppmt-robot-store-revenue"] }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -819,6 +819,18 @@ Object.entries(REGION_SERIES).forEach(([regionKey, seriesKeys]) => {
|
|||||||
...Object.fromEntries(seriesKeys.map((key) => [key, 1])),
|
...Object.fromEntries(seriesKeys.map((key) => [key, 1])),
|
||||||
total_revenue: 2,
|
total_revenue: 2,
|
||||||
});
|
});
|
||||||
|
// 金额视图与占比视图同源(披露金额,非占比×总收入还原),供分地区渠道金额堆叠柱图使用。
|
||||||
|
const amountViewsWithTotal = Object.fromEntries(Object.entries(amountViews).map(([view, rows]) => [
|
||||||
|
view,
|
||||||
|
rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
total_revenue: seriesKeys.reduce((sum, key) => sum + row[key], 0),
|
||||||
|
})),
|
||||||
|
]));
|
||||||
|
writeRuntimeViews(`ppmt_channel_amount_${regionKey}`, ['period', ...seriesKeys, 'total_revenue'], amountViewsWithTotal, {
|
||||||
|
...Object.fromEntries(seriesKeys.map((key) => [key, 1])),
|
||||||
|
total_revenue: 2,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('已生成四地区渠道收入占比(线上细分)的全部、年度、半年 CSV。');
|
console.log('已生成四地区渠道收入占比(线上细分)的全部、年度、半年 CSV。');
|
||||||
|
|||||||
Reference in New Issue
Block a user