Refactor financial chart implementation
This commit is contained in:
+1
-4
@@ -18,10 +18,7 @@ export function growthChart(rows, { width, height }) {
|
||||
...legendStyle,
|
||||
top: LAYOUT.legendTop,
|
||||
itemGap: 34,
|
||||
data: ['非国际归母净利', '毛利润', '营业收入', '营业成本'].map(name => ({
|
||||
name,
|
||||
icon: 'emptyCircle', // 全部折线图
|
||||
})),
|
||||
data: ['非国际归母净利', '毛利润', '营业收入', '营业成本'],
|
||||
},
|
||||
grid: { left: 84, right: 56, top: LAYOUT.gridTop, bottom: 64 },
|
||||
tooltip: {
|
||||
|
||||
@@ -8,7 +8,9 @@ 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';
|
||||
import { ppmtMarginsChart } from './ppmt_margins.js';
|
||||
import { ppmtBridgeChart } from './ppmt_bridge.js';
|
||||
@@ -17,6 +19,12 @@ import { ppmtCoreMarginChart } from './ppmt_core_margin.js';
|
||||
import { ppmtIpShareChart } from './ppmt_ip_share.js';
|
||||
import { ppmtIpShareHistoryChart } from './ppmt_ip_share_history.js';
|
||||
import { ppmtIpExMonstersChart } from './ppmt_ip_ex_monsters.js';
|
||||
import { cashConversionChart } from './ppmt_cash_conversion.js';
|
||||
import { liquidityWorkingCapitalChart } from './ppmt_liquidity_working_capital.js';
|
||||
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';
|
||||
|
||||
/** 图表构建函数注册表。manifest 只引用这里的 key,不直接引用 JS 函数。 */
|
||||
export const chartRegistry = {
|
||||
@@ -30,7 +38,9 @@ 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,
|
||||
'ppmt-margins': ppmtMarginsChart,
|
||||
'ppmt-bridge': ppmtBridgeChart,
|
||||
@@ -39,6 +49,12 @@ export const chartRegistry = {
|
||||
'ppmt-ip-share': ppmtIpShareChart,
|
||||
'ppmt-ip-share-history': ppmtIpShareHistoryChart,
|
||||
'ppmt-ip-ex-monsters': ppmtIpExMonstersChart,
|
||||
'ppmt-cash-conversion': cashConversionChart,
|
||||
'ppmt-liquidity-working-capital': liquidityWorkingCapitalChart,
|
||||
'ppmt-cashflow-composition': cashflowCompositionChart,
|
||||
'ppmt-working-capital-days': workingCapitalDaysChart,
|
||||
'ppmt-expense-structure': expenseStructureChart,
|
||||
'ppmt-channel-revenue': channelRevenueChart,
|
||||
};
|
||||
|
||||
export function getChartBuilder(key) {
|
||||
|
||||
@@ -17,11 +17,7 @@ export function inventoryChart(rows, { width, height }) {
|
||||
legend: {
|
||||
...legendStyle,
|
||||
top: LAYOUT.legendTop,
|
||||
data: [
|
||||
{ name: '存货', icon: 'rect' },
|
||||
{ name: '营收', icon: 'rect' },
|
||||
{ name: '存货周转天数', icon: 'emptyCircle' }, // 折线
|
||||
],
|
||||
data: ['存货', '营收', '存货周转天数'],
|
||||
},
|
||||
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
|
||||
tooltip: {
|
||||
|
||||
+1
-4
@@ -21,10 +21,7 @@ export function marginChart(rows, { width, height }) {
|
||||
legend: {
|
||||
...legendStyle,
|
||||
top: LAYOUT.legendTop,
|
||||
data: ['毛利率', '销售费用率', '管理费用率', '非国际归母净利率'].map(name => ({
|
||||
name,
|
||||
icon: 'emptyCircle', // 全部折线图
|
||||
})),
|
||||
data: ['毛利率', '销售费用率', '管理费用率', '非国际归母净利率'],
|
||||
},
|
||||
grid: { left: 72, right: 48, top: LAYOUT.gridTop, bottom: 62 },
|
||||
tooltip: {
|
||||
|
||||
@@ -21,10 +21,7 @@ export function assetsChart(rows, { width, height }) {
|
||||
legend: {
|
||||
...legendStyle,
|
||||
top: LAYOUT.legendTop,
|
||||
data: ['类现金', '应收账款', '投资资产', '经营类资产', '资产总计'].map(name => ({
|
||||
name,
|
||||
icon: name === '资产总计' ? 'emptyCircle' : 'rect', // 总计是折线,其他是柱
|
||||
})),
|
||||
data: ['类现金', '应收账款', '投资资产', '经营类资产', '资产总计'],
|
||||
},
|
||||
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
|
||||
tooltip: {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
FINANCIAL_COLORS, PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||
lineSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
/** 泡泡玛特经营现金流与净利润:观察利润的现金兑现能力。 */
|
||||
export function cashConversionChart(rows, { width, height }) {
|
||||
const periods = col(rows, 'period');
|
||||
|
||||
return {
|
||||
backgroundColor: '#fff',
|
||||
title: {
|
||||
...titleStyle,
|
||||
text: '泡泡玛特经营现金流与净利润(年度)',
|
||||
subtext: '现金转换率 = 经营活动现金流净额 / 净利润;单位:亿元',
|
||||
},
|
||||
legend: {
|
||||
...legendStyle,
|
||||
top: LAYOUT.legendTop,
|
||||
data: ['经营活动现金流净额', '净利润', '现金转换率'],
|
||||
},
|
||||
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
textStyle: { fontFamily: FONT },
|
||||
valueFormatter: (value) => `${value.toFixed(1)}`,
|
||||
},
|
||||
xAxis: { ...periodAxis, data: periods },
|
||||
yAxis: [
|
||||
{
|
||||
...axisCommon,
|
||||
type: 'value',
|
||||
name: '金额(亿元)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 52,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: 0,
|
||||
splitLine: { show: true, lineStyle: { color: PALETTE.gridLine } },
|
||||
},
|
||||
{
|
||||
...axisCommon,
|
||||
type: 'value',
|
||||
name: '现金转换率(%)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 58,
|
||||
nameRotate: -90,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: 0,
|
||||
max: 200,
|
||||
interval: 50,
|
||||
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '经营活动现金流净额',
|
||||
type: 'bar',
|
||||
data: col(rows, 'operating_cash_flow'),
|
||||
barWidth: 31.2,
|
||||
itemStyle: { color: FINANCIAL_COLORS.operatingCashFlow },
|
||||
label: { show: true, position: 'top', color: PALETTE.ink, fontFamily: FONT, fontSize: 11, formatter: (p) => p.value.toFixed(1) },
|
||||
},
|
||||
{
|
||||
name: '净利润',
|
||||
type: 'bar',
|
||||
data: col(rows, 'net_profit'),
|
||||
barWidth: 31.2,
|
||||
itemStyle: { color: FINANCIAL_COLORS.netProfit },
|
||||
label: { show: true, position: 'top', color: PALETTE.ink, fontFamily: FONT, fontSize: 11, formatter: (p) => p.value.toFixed(1) },
|
||||
},
|
||||
{
|
||||
...lineSeries({
|
||||
name: '现金转换率',
|
||||
data: col(rows, 'cash_conversion'),
|
||||
color: FINANCIAL_COLORS.cashConversion,
|
||||
formatter: (p) => `${p.value.toFixed(0)}%`,
|
||||
width: 3,
|
||||
}),
|
||||
yAxisIndex: 1,
|
||||
z: 10,
|
||||
},
|
||||
],
|
||||
graphic: [
|
||||
...watermarks(width, height),
|
||||
footnote('数据来源:泡泡玛特合并现金流量表、利润表(年度口径)| @思考的Joey'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
FINANCIAL_COLORS, PALETTE, FONT, axisCommon, periodAxis, titleStyle, standardLegend,
|
||||
lineDataWithSafeLabels,
|
||||
lineSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
/** 泡泡玛特三大现金流:展示经营造血、投资投入与筹资变化。 */
|
||||
export function cashflowCompositionChart(rows, { width, height }) {
|
||||
const netCashIncrease = lineDataWithSafeLabels(rows, {
|
||||
lineKey: 'net_cash_increase',
|
||||
barKeys: ['operating_cash_flow', 'investing_cash_flow', 'financing_cash_flow'],
|
||||
});
|
||||
|
||||
return {
|
||||
backgroundColor: '#fff',
|
||||
title: {
|
||||
...titleStyle,
|
||||
text: '泡泡玛特三大现金流与现金净增加(年度)',
|
||||
subtext: '单位:亿元;正值为流入,负值为流出',
|
||||
},
|
||||
// 不手工指定 icon,让 ECharts 按系列类型生成:柱为矩形、折线为短线 + 空心圆。
|
||||
legend: standardLegend(['经营活动现金流', '投资活动现金流', '筹资活动现金流', '现金净增加']),
|
||||
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
textStyle: { fontFamily: FONT },
|
||||
valueFormatter: (value) => `${value.toFixed(1)} 亿元`,
|
||||
},
|
||||
xAxis: { ...periodAxis, data: col(rows, 'period') },
|
||||
yAxis: {
|
||||
...axisCommon,
|
||||
type: 'value',
|
||||
name: '金额(亿元)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 52,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
},
|
||||
series: [
|
||||
{ name: '经营活动现金流', type: 'bar', data: col(rows, 'operating_cash_flow'), barWidth: 26, itemStyle: { color: FINANCIAL_COLORS.operatingCashFlow } },
|
||||
{ name: '投资活动现金流', type: 'bar', data: col(rows, 'investing_cash_flow'), barWidth: 26, itemStyle: { color: FINANCIAL_COLORS.investingCashFlow } },
|
||||
{ name: '筹资活动现金流', type: 'bar', data: col(rows, 'financing_cash_flow'), barWidth: 26, itemStyle: { color: FINANCIAL_COLORS.financingCashFlow } },
|
||||
{
|
||||
...lineSeries({
|
||||
name: '现金净增加',
|
||||
data: netCashIncrease,
|
||||
color: FINANCIAL_COLORS.netCashIncrease,
|
||||
formatter: (p) => p.value.toFixed(1),
|
||||
width: 3,
|
||||
}),
|
||||
labelLayout: { moveOverlap: 'shiftY', hideOverlap: true },
|
||||
z: 10,
|
||||
},
|
||||
],
|
||||
graphic: [
|
||||
...watermarks(width, height),
|
||||
footnote('数据来源:泡泡玛特合并现金流量表(年度口径)| @思考的Joey'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, legendStyle,
|
||||
footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
@@ -23,10 +23,7 @@ export function categoryChart(rows, { width, height }) {
|
||||
legend: {
|
||||
...legendStyle,
|
||||
top: LAYOUT.legendTop,
|
||||
data: ['毛绒', '手办', 'MEGA', '衍生品及其他'].map(name => ({
|
||||
name,
|
||||
icon: 'rect', // 全部是堆叠柱状图
|
||||
})),
|
||||
data: ['毛绒', '手办', 'MEGA', '衍生品及其他'],
|
||||
},
|
||||
grid: { left: 78, right: 56, top: LAYOUT.gridTop, bottom: 90 },
|
||||
tooltip: {
|
||||
@@ -44,7 +41,14 @@ export function categoryChart(rows, { width, height }) {
|
||||
return result;
|
||||
},
|
||||
},
|
||||
xAxis: { ...periodAxis, data: periods },
|
||||
xAxis: {
|
||||
...categoryAxis,
|
||||
data: periods,
|
||||
axisLabel: {
|
||||
...categoryAxis.axisLabel,
|
||||
formatter: (value) => String(value).replace(/^20/, ''),
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
...axisCommon,
|
||||
type: 'value',
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
CHANNEL_COLORS, PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||
lineSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
/** 泡泡玛特渠道收入结构与增速。 */
|
||||
export function channelRevenueChart(rows, { width, height }) {
|
||||
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 },
|
||||
];
|
||||
|
||||
return {
|
||||
backgroundColor: '#fff',
|
||||
title: {
|
||||
...titleStyle,
|
||||
text: '泡泡玛特渠道收入结构与增速',
|
||||
subtext: '渠道收入为堆叠柱;折线为营业收入同比增速',
|
||||
},
|
||||
legend: {
|
||||
...legendStyle,
|
||||
top: LAYOUT.legendTop,
|
||||
data: [...channels.map((item) => item.name), '营业收入同比'],
|
||||
},
|
||||
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
textStyle: { fontFamily: FONT },
|
||||
valueFormatter: (value) => value.toFixed(1),
|
||||
},
|
||||
xAxis: { ...periodAxis, data: col(rows, 'period') },
|
||||
yAxis: [
|
||||
{
|
||||
...axisCommon,
|
||||
type: 'value',
|
||||
name: '渠道收入(亿元)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 56,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
...axisCommon,
|
||||
type: 'value',
|
||||
name: '营业收入同比(%)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 60,
|
||||
nameRotate: -90,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: 0,
|
||||
max: 250,
|
||||
interval: 50,
|
||||
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
...channels.map((item) => ({
|
||||
name: item.name,
|
||||
type: 'bar',
|
||||
stack: 'channel-revenue',
|
||||
data: col(rows, item.key),
|
||||
itemStyle: { color: item.color },
|
||||
emphasis: { focus: 'series' },
|
||||
})),
|
||||
{
|
||||
...lineSeries({
|
||||
name: '营业收入同比',
|
||||
data: col(rows, 'revenue_yoy'),
|
||||
color: CHANNEL_COLORS.revenueGrowth,
|
||||
formatter: (p) => `${p.value.toFixed(0)}%`,
|
||||
width: 3,
|
||||
}),
|
||||
yAxisIndex: 1,
|
||||
z: 10,
|
||||
},
|
||||
],
|
||||
graphic: [
|
||||
...watermarks(width, height),
|
||||
footnote('数据来源:泡泡玛特营收构成表;渠道口径按财报披露 | @思考的Joey'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export function ppmtCoreProfitChart(rows, { width, height }) {
|
||||
subtext: '核心利润 = 毛利 - 销售费用 - 管理费用,单位:亿元',
|
||||
},
|
||||
legend: {
|
||||
...legendStyle,
|
||||
top: LAYOUT.legendTop,
|
||||
itemGap: 32,
|
||||
textStyle: {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||
lineSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
/** 泡泡玛特期间费用结构:观察销售投入、管理成本与费用率的规模效应。 */
|
||||
export function expenseStructureChart(rows, { width, height }) {
|
||||
return {
|
||||
backgroundColor: '#fff',
|
||||
title: {
|
||||
...titleStyle,
|
||||
text: '泡泡玛特费用结构与费用率(年度)',
|
||||
subtext: '销售、管理、财务费用;折线为四项费用率',
|
||||
},
|
||||
legend: {
|
||||
...legendStyle,
|
||||
top: LAYOUT.legendTop,
|
||||
data: ['销售费用', '管理费用', '财务费用(净额)', '四项费用率'],
|
||||
},
|
||||
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
textStyle: { fontFamily: FONT },
|
||||
valueFormatter: (value) => value.toFixed(1),
|
||||
},
|
||||
xAxis: { ...periodAxis, data: col(rows, 'period') },
|
||||
yAxis: [
|
||||
{
|
||||
...axisCommon,
|
||||
type: 'value',
|
||||
name: '金额(亿元)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 52,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
},
|
||||
{
|
||||
...axisCommon,
|
||||
type: 'value',
|
||||
name: '四项费用率(%)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 58,
|
||||
nameRotate: -90,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: 0,
|
||||
max: 50,
|
||||
interval: 10,
|
||||
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{ 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 } },
|
||||
{
|
||||
...lineSeries({
|
||||
name: '四项费用率',
|
||||
data: col(rows, 'four_expense_rate'),
|
||||
color: PALETTE.red,
|
||||
formatter: (p) => `${p.value.toFixed(1)}%`,
|
||||
width: 3,
|
||||
}),
|
||||
yAxisIndex: 1,
|
||||
z: 10,
|
||||
},
|
||||
],
|
||||
graphic: [
|
||||
...watermarks(width, height),
|
||||
footnote('数据来源:泡泡玛特合并利润表(年度口径)| @思考的Joey'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||
PALETTE, FONT, axisCommon, periodAxis, titleStyle, standardLegend,
|
||||
lineSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
@@ -53,17 +53,7 @@ export function ppmtGrowthChart(rows, { width, height }) {
|
||||
text: '泡泡玛特营收到利润的增速对比(2020H1-2026H1)',
|
||||
subtext: '同比增速(%),净利增速显著高于营收增速体现利润弹性',
|
||||
},
|
||||
legend: {
|
||||
// 不继承 legendStyle,让 ECharts 按系列自动生成「短线+空心圆」图例
|
||||
top: LAYOUT.legendTop,
|
||||
itemGap: 34,
|
||||
textStyle: {
|
||||
color: PALETTE.ink,
|
||||
fontSize: 14,
|
||||
fontFamily: FONT,
|
||||
},
|
||||
data: ['非GAAP归母净利', '毛利润', '营业收入', '营业成本'],
|
||||
},
|
||||
legend: standardLegend(['非GAAP归母净利', '毛利润', '营业收入', '营业成本'], { itemGap: 34 }),
|
||||
grid: { left: 84, right: 56, top: LAYOUT.gridTop, bottom: 80 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
|
||||
+10
-6
@@ -1,6 +1,6 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, legendStyle,
|
||||
lineSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
@@ -49,10 +49,7 @@ export function ipChart(rows, { width, height }) {
|
||||
...legendStyle,
|
||||
top: LAYOUT.legendTop,
|
||||
itemGap: 20,
|
||||
data: rows.map(r => ({
|
||||
name: r.ip,
|
||||
icon: 'emptyCircle', // 折线图:空心圆圈
|
||||
})),
|
||||
data: rows.map((row) => row.ip),
|
||||
},
|
||||
grid: { left: 78, right: 56, top: LAYOUT.gridTop, bottom: 70 },
|
||||
tooltip: {
|
||||
@@ -60,7 +57,14 @@ export function ipChart(rows, { width, height }) {
|
||||
valueFormatter: (v) => `${v.toFixed(2)} 亿元`,
|
||||
textStyle: { fontFamily: FONT },
|
||||
},
|
||||
xAxis: { ...periodAxis, data: periods },
|
||||
xAxis: {
|
||||
...categoryAxis,
|
||||
data: periods,
|
||||
axisLabel: {
|
||||
...categoryAxis.axisLabel,
|
||||
formatter: (value) => String(value).replace(/^20/, ''),
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
...axisCommon,
|
||||
type: 'value',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, categoryAxis, titleStyle,
|
||||
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, LEGEND_ITEM_WIDTH,
|
||||
barSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
@@ -58,7 +58,7 @@ export function ppmtIpShareHistoryChart(rows, { width, height }) {
|
||||
width: LEGEND_WIDTH,
|
||||
top: 'middle',
|
||||
itemGap: 12,
|
||||
itemWidth: 14,
|
||||
itemWidth: LEGEND_ITEM_WIDTH,
|
||||
itemHeight: 14,
|
||||
data: names,
|
||||
textStyle: { color: PALETTE.ink, fontSize: 12, fontFamily: FONT },
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||
lineSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
/** 泡泡玛特现金、负债与净营运资本:观察流动性与资金占用。 */
|
||||
export function liquidityWorkingCapitalChart(rows, { width, height }) {
|
||||
const periods = col(rows, 'period');
|
||||
|
||||
return {
|
||||
backgroundColor: '#fff',
|
||||
title: {
|
||||
...titleStyle,
|
||||
text: '泡泡玛特现金、负债与净营运资本',
|
||||
subtext: '净营运资本 = 流动资产 − 流动负债;单位:亿元',
|
||||
},
|
||||
legend: {
|
||||
...legendStyle,
|
||||
top: LAYOUT.legendTop,
|
||||
data: ['货币资金', '负债合计', '净营运资本'],
|
||||
},
|
||||
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
textStyle: { fontFamily: FONT },
|
||||
valueFormatter: (value) => `${value.toFixed(1)} 亿元`,
|
||||
},
|
||||
xAxis: { ...periodAxis, data: periods },
|
||||
yAxis: {
|
||||
...axisCommon,
|
||||
type: 'value',
|
||||
name: '金额(亿元)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 52,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: 0,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '货币资金',
|
||||
type: 'bar',
|
||||
data: col(rows, 'cash'),
|
||||
barWidth: 18,
|
||||
itemStyle: { color: PALETTE.blue },
|
||||
emphasis: { focus: 'series' },
|
||||
},
|
||||
{
|
||||
name: '负债合计',
|
||||
type: 'bar',
|
||||
data: col(rows, 'total_liabilities'),
|
||||
barWidth: 18,
|
||||
itemStyle: { color: PALETTE.orange },
|
||||
emphasis: { focus: 'series' },
|
||||
},
|
||||
{
|
||||
...lineSeries({
|
||||
name: '净营运资本',
|
||||
data: col(rows, 'net_working_capital'),
|
||||
color: PALETTE.red,
|
||||
formatter: (p) => p.value.toFixed(1),
|
||||
width: 3,
|
||||
}),
|
||||
z: 10,
|
||||
},
|
||||
],
|
||||
graphic: [
|
||||
...watermarks(width, height),
|
||||
footnote('数据来源:泡泡玛特合并资产负债表 | @思考的Joey'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||
PALETTE, FONT, axisCommon, periodAxis, titleStyle, standardLegend,
|
||||
lineSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
@@ -64,18 +64,7 @@ export function ppmtMarginsChart(rows, { width, height }) {
|
||||
text: '泡泡玛特利润率与费用率拆解(2020H1-2026H1)',
|
||||
subtext: '毛利率抬升叠加费用率下降,共同推动净利率走高(H1为半年,H2为全年累计)',
|
||||
},
|
||||
legend: {
|
||||
// 不继承 legendStyle:它带 icon:'roundRect',会盖掉 ECharts 按系列
|
||||
// 自动生成的「短线+空心圆」图例
|
||||
top: LAYOUT.legendTop,
|
||||
itemGap: 28,
|
||||
textStyle: {
|
||||
color: PALETTE.ink,
|
||||
fontSize: 14,
|
||||
fontFamily: FONT,
|
||||
},
|
||||
data: SERIES.map((s) => s.name),
|
||||
},
|
||||
legend: standardLegend(SERIES.map((series) => series.name)),
|
||||
grid: { left: 78, right: 56, top: LAYOUT.gridTop, bottom: 80 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
|
||||
@@ -25,12 +25,7 @@ export function profitChart(rows, { width, height }) {
|
||||
legend: {
|
||||
...legendStyle,
|
||||
top: LAYOUT.legendTop,
|
||||
data: [
|
||||
{ name: '营业收入', icon: 'rect' },
|
||||
{ name: '毛利润', icon: 'rect' },
|
||||
{ name: '归母净利润', icon: 'rect' },
|
||||
{ name: '毛利率', icon: 'emptyCircle' },
|
||||
],
|
||||
data: ['营业收入', '毛利润', '归母净利润', '毛利率'],
|
||||
},
|
||||
grid: { left: 78, right: 82, top: LAYOUT.gridTop, bottom: 90 },
|
||||
tooltip: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
PALETTE, FONT, titleStyle, footnote, watermarks,
|
||||
PALETTE, FONT, titleStyle, footnote, watermarks, legendStyle,
|
||||
LAYOUT, } from '../theme.js';
|
||||
|
||||
/**
|
||||
@@ -44,7 +44,8 @@ export function regionChannelChart(rows, { width, height }) {
|
||||
top: 18,
|
||||
},
|
||||
legend: {
|
||||
data: channels.map(name => ({ name, icon: 'rect' })), // 全部堆叠柱状图
|
||||
...legendStyle,
|
||||
data: channels,
|
||||
top: LAYOUT.legendTop,
|
||||
itemGap: 32,
|
||||
textStyle: { fontFamily: FONT, fontSize: 13 },
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
FONT, PALETTE, categoryAxis, footnote, titleStyle, watermarks,
|
||||
} from '../theme.js';
|
||||
|
||||
const REGION_ORDER = ['中国', '海外', '亚太', '美洲', '欧洲及其他'];
|
||||
const CHANNEL_ORDER = ['线下渠道', '线上渠道', '批发及其他'];
|
||||
|
||||
function orderedValues(values, order) {
|
||||
return [...values].sort((a, b) => {
|
||||
const aIndex = order.indexOf(a);
|
||||
const bIndex = order.indexOf(b);
|
||||
return (aIndex === -1 ? order.length : aIndex) - (bIndex === -1 ? order.length : bIndex)
|
||||
|| a.localeCompare(b, 'zh-CN');
|
||||
});
|
||||
}
|
||||
|
||||
function orderedPeriods(rows) {
|
||||
return [...new Set(rows.map((row) => row.period))].sort((a, b) => {
|
||||
const [yearA, halfA] = String(a).match(/(\d{4})H(\d)/).slice(1).map(Number);
|
||||
const [yearB, halfB] = String(b).match(/(\d{4})H(\d)/).slice(1).map(Number);
|
||||
return yearA - yearB || halfA - halfB;
|
||||
});
|
||||
}
|
||||
|
||||
/** 泡泡玛特区域 × 渠道同比增速热力图。 */
|
||||
export function regionChannelYoyTimeseriesChart(rows, { width, height }) {
|
||||
const periods = orderedPeriods(rows);
|
||||
const regions = orderedValues(new Set(rows.map((row) => row.region)), REGION_ORDER);
|
||||
const channels = orderedValues(new Set(rows.map((row) => row.channel)), CHANNEL_ORDER);
|
||||
const dimensions = regions.flatMap((region) => channels.map((channel) => ({ region, channel })));
|
||||
const periodIndex = new Map(periods.map((period, index) => [period, index]));
|
||||
const dimensionIndex = new Map(dimensions.map(({ region, channel }, index) => [`${region}_${channel}`, index]));
|
||||
const data = rows.map((row) => [
|
||||
periodIndex.get(row.period),
|
||||
dimensionIndex.get(`${row.region}_${row.channel}`),
|
||||
row.yoy,
|
||||
]);
|
||||
|
||||
return {
|
||||
backgroundColor: '#fff',
|
||||
title: {
|
||||
...titleStyle,
|
||||
text: '泡泡玛特区域渠道同比增速趋势',
|
||||
subtext: '颜色越深代表同比变化幅度越大;空白表示当期未单列披露',
|
||||
},
|
||||
tooltip: {
|
||||
position: 'top',
|
||||
textStyle: { fontFamily: FONT },
|
||||
formatter: (params) => {
|
||||
const [periodIndexValue, dimensionIndexValue, value] = params.value;
|
||||
const { region, channel } = dimensions[dimensionIndexValue];
|
||||
const sign = value >= 0 ? '+' : '';
|
||||
return `<b>${periods[periodIndexValue]}</b><br/>${region} · ${channel}<br/>同比:${sign}${value.toFixed(1)}%`;
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 178,
|
||||
right: 42,
|
||||
top: 112,
|
||||
// 底部依次预留:横轴标签 → visualMap 控制器 → 数据来源。
|
||||
bottom: 155,
|
||||
},
|
||||
xAxis: {
|
||||
...categoryAxis,
|
||||
data: periods,
|
||||
axisLabel: {
|
||||
...categoryAxis.axisLabel,
|
||||
formatter: (value) => String(value).replace(/^20/, ''),
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
...categoryAxis,
|
||||
type: 'category',
|
||||
inverse: true,
|
||||
data: dimensions.map(({ region, channel }) => `${region} · ${channel}`),
|
||||
axisLabel: {
|
||||
...categoryAxis.axisLabel,
|
||||
fontSize: 12,
|
||||
margin: 12,
|
||||
},
|
||||
},
|
||||
visualMap: {
|
||||
min: -100,
|
||||
max: 200,
|
||||
calculable: true,
|
||||
orient: 'horizontal',
|
||||
left: 'center',
|
||||
bottom: 64,
|
||||
text: ['较高增长', '较低增长'],
|
||||
textStyle: { color: PALETTE.grey, fontFamily: FONT, fontSize: 12 },
|
||||
// 红色系代表较高增长;两端均采用浅色,保证数值标签清晰。
|
||||
inRange: { color: ['#9cc4d0', '#f3eee6', PALETTE.coral] },
|
||||
},
|
||||
series: [{
|
||||
name: '同比增速',
|
||||
type: 'heatmap',
|
||||
data,
|
||||
label: {
|
||||
show: true,
|
||||
fontFamily: FONT,
|
||||
fontSize: 11,
|
||||
color: PALETTE.ink,
|
||||
formatter: (params) => {
|
||||
const value = params.value[2];
|
||||
return `${value >= 0 ? '+' : ''}${value.toFixed(0)}%`;
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: { borderColor: PALETTE.ink, borderWidth: 1 },
|
||||
},
|
||||
}],
|
||||
graphic: [
|
||||
...watermarks(width, height),
|
||||
footnote('数据来源:泡泡玛特财报;早期海外为合并口径 | @思考的Joey'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
PALETTE, FONT, titleStyle, footnote, watermarks,
|
||||
PALETTE, FONT, titleStyle, footnote, watermarks, legendStyle,
|
||||
LAYOUT, } from '../theme.js';
|
||||
|
||||
/**
|
||||
@@ -42,7 +42,8 @@ export function regionChannelYoyChart(rows, { width, height }) {
|
||||
top: 18,
|
||||
},
|
||||
legend: {
|
||||
data: channels.map(name => ({ name, icon: 'rect' })), // 全部柱状图
|
||||
...legendStyle,
|
||||
data: channels,
|
||||
top: LAYOUT.legendTop,
|
||||
itemGap: 32,
|
||||
textStyle: { fontFamily: FONT, fontSize: 13 },
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||
lineSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
/** 泡泡玛特营运资本效率:存货、应收、应付与现金转换周期。 */
|
||||
export function workingCapitalDaysChart(rows, { width, height }) {
|
||||
const series = [
|
||||
{ key: 'inventory_days', name: '存货周转天数', color: PALETTE.orange, width: 2.4 },
|
||||
{ key: 'receivable_days', name: '应收周转天数', color: PALETTE.blue, width: 2.2 },
|
||||
{ key: 'payable_days', name: '应付周转天数', color: PALETTE.purple, width: 2.2 },
|
||||
{ key: 'cash_conversion_cycle', name: '现金转换周期', color: PALETTE.red, width: 3.2 },
|
||||
];
|
||||
|
||||
return {
|
||||
backgroundColor: '#fff',
|
||||
title: {
|
||||
...titleStyle,
|
||||
text: '泡泡玛特营运资本效率',
|
||||
subtext: '现金转换周期 = 存货周转天数 + 应收周转天数 − 应付周转天数',
|
||||
},
|
||||
legend: { ...legendStyle, top: LAYOUT.legendTop, data: series.map((item) => item.name) },
|
||||
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
textStyle: { fontFamily: FONT },
|
||||
valueFormatter: (value) => `${value.toFixed(1)} 天`,
|
||||
},
|
||||
xAxis: { ...periodAxis, data: col(rows, 'period') },
|
||||
yAxis: {
|
||||
...axisCommon,
|
||||
type: 'value',
|
||||
name: '天数',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 42,
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: 0,
|
||||
},
|
||||
series: series.map((item) => ({
|
||||
...lineSeries({
|
||||
name: item.name,
|
||||
data: col(rows, item.key),
|
||||
color: item.color,
|
||||
formatter: (p) => p.value.toFixed(0),
|
||||
width: item.width,
|
||||
}),
|
||||
label: item.key === 'cash_conversion_cycle'
|
||||
? { show: true, color: item.color, fontFamily: FONT, fontSize: 11, formatter: (p) => `${p.value.toFixed(0)}天` }
|
||||
: { show: false },
|
||||
z: item.key === 'cash_conversion_cycle' ? 10 : 1,
|
||||
})),
|
||||
graphic: [
|
||||
...watermarks(width, height),
|
||||
footnote('数据来源:泡泡玛特财务指标(合并报表)| @思考的Joey'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -15,10 +15,7 @@ export function twoLineChart(rows, { width, height }) {
|
||||
legend: {
|
||||
...legendStyle,
|
||||
top: LAYOUT.legendTop,
|
||||
data: ['毛利率', '非国际归母净利率'].map(name => ({
|
||||
name,
|
||||
icon: 'emptyCircle', // 全部折线图
|
||||
})),
|
||||
data: ['毛利率', '非国际归母净利率'],
|
||||
},
|
||||
grid: { left: 72, right: 48, top: LAYOUT.gridTop, bottom: 62 },
|
||||
tooltip: {
|
||||
|
||||
+45
-25
@@ -22,21 +22,31 @@ function validateManifest(value) {
|
||||
throw new Error('manifest 缺少 companies');
|
||||
}
|
||||
|
||||
const chartIds = new Set();
|
||||
value.companies.forEach((company) => {
|
||||
if (!company.id || !Array.isArray(company.tabs)) throw new Error('manifest 中存在无效公司配置');
|
||||
if (!company.id || !Array.isArray(company.tabs) || !company.chartCatalog) {
|
||||
throw new Error('manifest 中存在无效公司配置');
|
||||
}
|
||||
Object.entries(company.chartCatalog).forEach(([chartId, chart]) => {
|
||||
if (!chartId || !getChartBuilder(chart.key)) {
|
||||
throw new Error(`图表 ${chartId || '(空)'} 引用了未注册 key:${chart.key}`);
|
||||
}
|
||||
if (Boolean(chart.csv) === Boolean(chart.views)) {
|
||||
throw new Error(`图表 ${chartId} 必须声明 csv 或 views 之一`);
|
||||
}
|
||||
if (chart.views && (!chart.defaultView || !chart.views[chart.defaultView])) {
|
||||
throw new Error(`图表 ${chartId} 的 defaultView 无效`);
|
||||
}
|
||||
});
|
||||
const tabIds = new Set();
|
||||
company.tabs.forEach((tab) => {
|
||||
if (!tab.id || tabIds.has(tab.id)) throw new Error(`公司 ${company.id} 的 tab id 重复或为空`);
|
||||
if (!Array.isArray(tab.chartRefs)) throw new Error(`公司 ${company.id} 的 ${tab.id} 缺少 chartRefs`);
|
||||
tabIds.add(tab.id);
|
||||
(tab.charts || []).forEach((chart) => {
|
||||
if (!chart.id || chartIds.has(chart.id)) throw new Error(`图表 id 重复或为空:${chart.id || '(空)'}`);
|
||||
if (!getChartBuilder(chart.key)) throw new Error(`图表 ${chart.id} 引用了未注册 key:${chart.key}`);
|
||||
if (Boolean(chart.csv) === Boolean(chart.views)) throw new Error(`图表 ${chart.id} 必须声明 csv 或 views 之一`);
|
||||
if (chart.views && (!chart.defaultView || !chart.views[chart.defaultView])) {
|
||||
throw new Error(`图表 ${chart.id} 的 defaultView 无效`);
|
||||
}
|
||||
chartIds.add(chart.id);
|
||||
const refs = new Set();
|
||||
tab.chartRefs.forEach((chartId) => {
|
||||
if (!company.chartCatalog[chartId]) throw new Error(`栏目 ${tab.id} 引用了不存在的图表:${chartId}`);
|
||||
if (refs.has(chartId)) throw new Error(`栏目 ${tab.id} 重复引用图表:${chartId}`);
|
||||
refs.add(chartId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -50,6 +60,15 @@ function getTab(company, tabId) {
|
||||
return company.tabs.find((tab) => tab.id === tabId) || company.tabs[0];
|
||||
}
|
||||
|
||||
function chartsForTab(company, tab) {
|
||||
return tab.chartRefs.map((chartId) => ({
|
||||
...company.chartCatalog[chartId],
|
||||
id: chartId,
|
||||
instanceId: `${company.id}--${tab.id}--${chartId}`,
|
||||
viewKey: `${company.id}--${chartId}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function parseHash() {
|
||||
const [path = '', query = ''] = window.location.hash.replace(/^#\/?/, '').split('?');
|
||||
const [companyId, tabId, chartId] = path.split('/').filter(Boolean).map(decodeURIComponent);
|
||||
@@ -60,11 +79,11 @@ function parseHash() {
|
||||
function normalizeState(nextState) {
|
||||
const company = getCompany(nextState.companyId);
|
||||
const tab = getTab(company, nextState.tabId);
|
||||
const chart = tab.charts.find((item) => item.id === nextState.chartId);
|
||||
const chart = chartsForTab(company, tab).find((item) => item.id === nextState.chartId);
|
||||
const view = chart?.views && chart.views[nextState.view]
|
||||
? nextState.view
|
||||
: chart?.defaultView;
|
||||
if (chart?.views && view) activeViews.set(chart.id, view);
|
||||
if (chart?.views && view) activeViews.set(chart.viewKey, view);
|
||||
return { companyId: company.id, tabId: tab.id, chartId: chart?.id, view };
|
||||
}
|
||||
|
||||
@@ -83,12 +102,12 @@ function disposeAll() {
|
||||
|
||||
function chartSource(chart) {
|
||||
if (!chart.views) return chart.csv;
|
||||
const view = activeViews.get(chart.id) || chart.defaultView;
|
||||
const view = activeViews.get(chart.viewKey) || chart.defaultView;
|
||||
return chart.views[view];
|
||||
}
|
||||
|
||||
async function renderChart(chart, version) {
|
||||
const el = document.getElementById(`chart-${chart.id}`);
|
||||
const el = document.getElementById(`chart-${chart.instanceId}`);
|
||||
if (!el) return;
|
||||
const source = chartSource(chart);
|
||||
|
||||
@@ -97,11 +116,11 @@ async function renderChart(chart, version) {
|
||||
if (version !== renderVersion || !document.body.contains(el)) return;
|
||||
|
||||
const instance = echarts.init(el, null, { renderer: 'canvas' });
|
||||
instances.set(chart.id, instance);
|
||||
instances.set(chart.instanceId, instance);
|
||||
const { width, height } = el.getBoundingClientRect();
|
||||
instance.setOption(getChartBuilder(chart.key)(rows, { width, height }), true);
|
||||
} catch (error) {
|
||||
if (version === renderVersion) showChartError(chart.id, error.message);
|
||||
if (version === renderVersion) showChartError(chart.instanceId, error.message);
|
||||
console.error(`渲染图表失败:${chart.id}`, error);
|
||||
}
|
||||
}
|
||||
@@ -125,16 +144,17 @@ function updateHash(nextState) {
|
||||
window.location.hash = hash;
|
||||
}
|
||||
|
||||
function selectAnchor(chartId) {
|
||||
state = { ...state, chartId, view: activeViews.get(chartId) };
|
||||
function selectAnchor(chart) {
|
||||
state = { ...state, chartId: chart.id, view: activeViews.get(chart.viewKey) };
|
||||
history.replaceState(null, '', serializeHash(state));
|
||||
document.getElementById(`card-${chartId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
document.getElementById(`card-${chart.instanceId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function renderCurrent() {
|
||||
state = normalizeState(state);
|
||||
const company = getCompany(state.companyId);
|
||||
const tab = getTab(company, state.tabId);
|
||||
const charts = chartsForTab(company, tab);
|
||||
const version = ++renderVersion;
|
||||
disposeAll();
|
||||
|
||||
@@ -144,15 +164,15 @@ function renderCurrent() {
|
||||
renderTabs(shell.tabs, company.tabs, tab.id, (tabId) => {
|
||||
updateHash({ companyId: company.id, tabId });
|
||||
});
|
||||
renderCards(shell.cards, tab.charts || [], activeViews, {
|
||||
renderCards(shell.cards, charts, activeViews, {
|
||||
onExport: exportPNG,
|
||||
onSelectView: (chartId, view) => {
|
||||
activeViews.set(chartId, view);
|
||||
updateHash({ companyId: company.id, tabId: tab.id, chartId, view });
|
||||
onSelectView: (chart, view) => {
|
||||
activeViews.set(chart.viewKey, view);
|
||||
updateHash({ companyId: company.id, tabId: tab.id, chartId: chart.id, view });
|
||||
},
|
||||
});
|
||||
renderAnchors(shell.chartNav, tab.charts || [], selectAnchor);
|
||||
Promise.all((tab.charts || []).map((chart) => renderChart(chart, version)));
|
||||
renderAnchors(shell.chartNav, charts, selectAnchor);
|
||||
Promise.all(charts.map((chart) => renderChart(chart, version)));
|
||||
}
|
||||
|
||||
async function start() {
|
||||
|
||||
+52
-4
@@ -27,6 +27,27 @@ export const PALETTE = {
|
||||
axisLine: '#d0d0d0',
|
||||
};
|
||||
|
||||
/**
|
||||
* 试行语义色:柱线组合图中,柱采用低饱和辅助色,折线保留单一强调色。
|
||||
* 待页面视觉确认后再固化为新增图表的正式规范。
|
||||
*/
|
||||
export const FINANCIAL_COLORS = {
|
||||
operatingCashFlow: '#6E9A83',
|
||||
investingCashFlow: '#7897B2',
|
||||
financingCashFlow: '#B18A66',
|
||||
netCashIncrease: '#A8465C',
|
||||
netProfit: '#C9885C',
|
||||
cashConversion: '#6B4A6C',
|
||||
};
|
||||
|
||||
export const CHANNEL_COLORS = {
|
||||
retail: '#597A9B',
|
||||
online: '#C87368',
|
||||
robotStore: '#98A77A',
|
||||
wholesale: '#BB9A5C',
|
||||
revenueGrowth: '#775D8B',
|
||||
};
|
||||
|
||||
/** 坐标轴通用样式:只留浅色横向网格,去掉轴线的存在感 */
|
||||
export const axisCommon = {
|
||||
axisLine: { show: false },
|
||||
@@ -104,13 +125,14 @@ export const LAYOUT = {
|
||||
gridTop: 145, // 绘图区顶部边距(图例到绘图区约 60px)
|
||||
};
|
||||
|
||||
// 统一图例图标宽度,同时保留 ECharts 按系列类型生成的形状。
|
||||
export const LEGEND_ITEM_WIDTH = 20;
|
||||
|
||||
export const legendStyle = {
|
||||
top: 62, // ⚠️ 已废弃:请改用 LAYOUT.legendTop 或 standardLegend()
|
||||
itemGap: 26,
|
||||
itemWidth: 18,
|
||||
// ❌ 删除 icon: 'roundRect'
|
||||
// 原因:它会覆盖 ECharts 按系列类型自动生成的图例样式(折线图的"短线+空心圆")
|
||||
// 现在让每个图表按需设置,或使用 standardLegend() 获得正确默认值
|
||||
itemWidth: LEGEND_ITEM_WIDTH,
|
||||
// 不设置 icon:柱状图自动使用矩形,折线图自动使用“短线 + 空心圆”。
|
||||
textStyle: {
|
||||
color: PALETTE.ink,
|
||||
fontSize: 14,
|
||||
@@ -139,6 +161,7 @@ export function labelStyle(color, position = 'top') {
|
||||
export function standardLegend(data, options = {}) {
|
||||
return {
|
||||
top: LAYOUT.legendTop,
|
||||
itemWidth: LEGEND_ITEM_WIDTH,
|
||||
itemGap: options.itemGap ?? 28,
|
||||
textStyle: {
|
||||
color: PALETTE.ink,
|
||||
@@ -176,6 +199,31 @@ export function lineSeries({ name, data, color, labelPosition = 'top', formatter
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 柱线组合图的折线标签:正值放在点上方、负值放在点下方;
|
||||
* 若与同期间柱值接近,则加大距离,避免文字压在柱体端点附近。
|
||||
*/
|
||||
export function lineDataWithSafeLabels(rows, { lineKey, barKeys, distance = 8, closeDistance = 14 }) {
|
||||
const values = rows.map((row) => row[lineKey]).filter(Number.isFinite);
|
||||
const range = Math.max(...values) - Math.min(...values) || 1;
|
||||
const threshold = range * 0.06;
|
||||
|
||||
return rows.map((row) => {
|
||||
const value = row[lineKey];
|
||||
const nearestBarGap = Math.min(...barKeys
|
||||
.map((key) => row[key])
|
||||
.filter(Number.isFinite)
|
||||
.map((barValue) => Math.abs(value - barValue)));
|
||||
return {
|
||||
value,
|
||||
label: {
|
||||
position: value < 0 ? 'bottom' : 'top',
|
||||
distance: nearestBarGap <= threshold ? closeDistance : distance,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 柱状图系列标准配置
|
||||
* 强制显示所有标签(overflow: 'none'),避免小柱子标签被 ECharts 自动隐藏
|
||||
|
||||
@@ -16,10 +16,9 @@ function button(label, { active = false, onClick, className = '' } = {}) {
|
||||
export function createAppShell(root, { onReload }) {
|
||||
root.replaceChildren();
|
||||
|
||||
const header = element('header', 'app-header');
|
||||
const brand = element('div', 'app-brand');
|
||||
brand.append(element('h1', '', '财报图表'), element('p', '', '选择公司与分析维度,数据更新后可重新加载。'));
|
||||
header.append(brand, button('重新加载数据', { onClick: onReload, className: 'reload-button' }));
|
||||
const reloadButton = button('↻', { onClick: onReload, className: 'reload-button' });
|
||||
reloadButton.title = '重新加载数据';
|
||||
reloadButton.setAttribute('aria-label', '重新加载数据');
|
||||
|
||||
const layout = element('div', 'app-layout');
|
||||
const companyNav = element('aside', 'sidebar company-sidebar');
|
||||
@@ -29,7 +28,7 @@ export function createAppShell(root, { onReload }) {
|
||||
content.append(tabs, cards);
|
||||
const chartNav = element('aside', 'sidebar chart-sidebar');
|
||||
layout.append(companyNav, content, chartNav);
|
||||
root.append(header, layout);
|
||||
root.append(layout, reloadButton);
|
||||
|
||||
return { companyNav, tabs, cards, chartNav };
|
||||
}
|
||||
@@ -67,13 +66,13 @@ export function renderCards(container, charts, activeViews, { onExport, onSelect
|
||||
|
||||
charts.forEach((chart) => {
|
||||
const card = element('section', 'card');
|
||||
card.id = `card-${chart.id}`;
|
||||
card.id = `card-${chart.instanceId}`;
|
||||
const bar = element('div', 'card-bar');
|
||||
const heading = element('div', 'card-heading');
|
||||
heading.append(element('h2', 'card-title', chart.title));
|
||||
|
||||
const viewNames = Object.keys(chart.views || {});
|
||||
const activeView = activeViews.get(chart.id) || chart.defaultView || viewNames[0];
|
||||
const activeView = activeViews.get(chart.viewKey) || chart.defaultView || viewNames[0];
|
||||
const source = chart.views ? chart.views[activeView] : chart.csv;
|
||||
heading.append(element('span', 'src', source));
|
||||
bar.append(heading);
|
||||
@@ -84,7 +83,7 @@ export function renderCards(container, charts, activeViews, { onExport, onSelect
|
||||
viewNames.forEach((view) => {
|
||||
switcher.append(button(labels[view] || view, {
|
||||
active: view === activeView,
|
||||
onClick: () => onSelectView(chart.id, view),
|
||||
onClick: () => onSelectView(chart, view),
|
||||
className: 'view-button',
|
||||
}));
|
||||
});
|
||||
@@ -92,11 +91,11 @@ export function renderCards(container, charts, activeViews, { onExport, onSelect
|
||||
}
|
||||
|
||||
bar.append(button('导出 PNG', {
|
||||
onClick: () => onExport(chart.id, chart.filename || chart.title),
|
||||
onClick: () => onExport(chart.instanceId, chart.filename || chart.title),
|
||||
className: 'export-button',
|
||||
}));
|
||||
const chartEl = element('div', 'chart');
|
||||
chartEl.id = `chart-${chart.id}`;
|
||||
chartEl.id = `chart-${chart.instanceId}`;
|
||||
chartEl.style.height = `${chart.height || 680}px`;
|
||||
card.append(bar, chartEl);
|
||||
container.append(card);
|
||||
|
||||
Reference in New Issue
Block a user