Standardize financial chart colors and improve label placement
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, titleStyle, footnote, watermarks,
|
||||
PALETTE, BRIDGE_COLORS, FONT, axisCommon, titleStyle, footnote, watermarks,
|
||||
} from '../theme.js';
|
||||
|
||||
/**
|
||||
@@ -13,12 +13,6 @@ import {
|
||||
* 税率变化单独成项。恒等式 净利率 = 税前利润率 × (1-税率),
|
||||
* Δ净 = Δ税前×(1-t₁) − 税前₂×Δt,实测闭合误差 0.00pct。
|
||||
*/
|
||||
const COLOR = {
|
||||
total: '#2F5C85', // 首尾锚点
|
||||
neg: '#C0392B', // 拖累
|
||||
pos: '#1A9E5C', // 利好
|
||||
};
|
||||
|
||||
export function ppmtBridgeChart(rows, { width, height }) {
|
||||
const items = col(rows, 'item');
|
||||
const types = col(rows, 'type');
|
||||
@@ -92,7 +86,7 @@ export function ppmtBridgeChart(rows, { width, height }) {
|
||||
barWidth: 51,
|
||||
data: values.map((v, i) => ({
|
||||
value: v,
|
||||
itemStyle: { color: COLOR[types[i]] },
|
||||
itemStyle: { color: BRIDGE_COLORS[types[i]] },
|
||||
})),
|
||||
label: {
|
||||
show: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||
PALETTE, FINANCIAL_COLORS, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||
footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ppmtCoreProfitChart(rows, { width, height }) {
|
||||
yAxisIndex: 0,
|
||||
data: core,
|
||||
barWidth: 32,
|
||||
itemStyle: { color: PALETTE.navy },
|
||||
itemStyle: { color: FINANCIAL_COLORS.netProfit },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
@@ -107,12 +107,12 @@ export function ppmtCoreProfitChart(rows, { width, height }) {
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: yoy,
|
||||
lineStyle: { color: PALETTE.coral, width: 3.08 }, // 与拆解图毛利率线一致
|
||||
lineStyle: { color: FINANCIAL_COLORS.cashConversion, width: 3.08 },
|
||||
// 空心圆点:白心 + 彩色描边。图例会自动继承这个样式,
|
||||
// 渲染成「短线 + 空心圆」
|
||||
itemStyle: {
|
||||
color: '#fff',
|
||||
borderColor: PALETTE.coral,
|
||||
borderColor: FINANCIAL_COLORS.cashConversion,
|
||||
borderWidth: 2.5, // 与 theme.js lineSeries 一致
|
||||
},
|
||||
symbol: 'circle',
|
||||
@@ -123,7 +123,7 @@ export function ppmtCoreProfitChart(rows, { width, height }) {
|
||||
position: 'top',
|
||||
fontFamily: FONT,
|
||||
fontSize: 11,
|
||||
color: PALETTE.coral,
|
||||
color: FINANCIAL_COLORS.cashConversion,
|
||||
fontWeight: '600',
|
||||
formatter: (p) => {
|
||||
if (p.value === null || p.value === '') return '';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||
lineSeries, footnote, watermarks, LAYOUT,
|
||||
PALETTE, FINANCIAL_COLORS, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||
lineDataWithSafeLabels, lineSeries, barSeries, footnote, watermarks, LAYOUT,
|
||||
} from '../theme.js';
|
||||
|
||||
/** 泡泡玛特现金、负债与净营运资本:观察流动性与资金占用。 */
|
||||
@@ -39,26 +39,35 @@ export function liquidityWorkingCapitalChart(rows, { width, height }) {
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '货币资金',
|
||||
type: 'bar',
|
||||
data: col(rows, 'cash'),
|
||||
barWidth: 18,
|
||||
itemStyle: { color: PALETTE.blue },
|
||||
...barSeries({
|
||||
name: '货币资金',
|
||||
data: col(rows, 'cash'),
|
||||
color: FINANCIAL_COLORS.investingCashFlow,
|
||||
barWidth: 18,
|
||||
formatter: (p) => p.value.toFixed(1),
|
||||
}),
|
||||
emphasis: { focus: 'series' },
|
||||
},
|
||||
{
|
||||
name: '负债合计',
|
||||
type: 'bar',
|
||||
data: col(rows, 'total_liabilities'),
|
||||
barWidth: 18,
|
||||
itemStyle: { color: PALETTE.orange },
|
||||
...barSeries({
|
||||
name: '负债合计',
|
||||
data: col(rows, 'total_liabilities'),
|
||||
color: FINANCIAL_COLORS.financingCashFlow,
|
||||
barWidth: 18,
|
||||
formatter: (p) => p.value.toFixed(1),
|
||||
}),
|
||||
emphasis: { focus: 'series' },
|
||||
},
|
||||
{
|
||||
...lineSeries({
|
||||
name: '净营运资本',
|
||||
data: col(rows, 'net_working_capital'),
|
||||
color: PALETTE.red,
|
||||
data: lineDataWithSafeLabels(rows, {
|
||||
lineKey: 'net_working_capital',
|
||||
barKeys: ['cash', 'total_liabilities'],
|
||||
// 净营运资本接近货币资金时,标签置于柱体另一侧。
|
||||
closePosition: 'bottom',
|
||||
}),
|
||||
color: FINANCIAL_COLORS.operatingCashFlow,
|
||||
formatter: (p) => p.value.toFixed(1),
|
||||
width: 3,
|
||||
}),
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
import { col } from '../csv.js';
|
||||
import {
|
||||
PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle,
|
||||
PALETTE, FINANCIAL_COLORS, 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 },
|
||||
];
|
||||
const SERIES = [
|
||||
{ key: 'inventory_days', name: '存货周转天数', color: FINANCIAL_COLORS.financingCashFlow, width: 2.4 },
|
||||
{ key: 'receivable_days', name: '应收周转天数', color: FINANCIAL_COLORS.investingCashFlow, width: 2.2 },
|
||||
{ key: 'payable_days', name: '应付周转天数', color: FINANCIAL_COLORS.cashConversion, width: 2.2 },
|
||||
{ key: 'cash_conversion_cycle', name: '现金转换周期', color: FINANCIAL_COLORS.netCashIncrease, width: 3.2 },
|
||||
];
|
||||
|
||||
/**
|
||||
* 同一期间的多条线将标签放到离最近相邻值更远的一侧,确保每个数值都可见。
|
||||
*/
|
||||
function labeledData(rows, key) {
|
||||
return rows.map((row) => {
|
||||
const value = row[key];
|
||||
const ranked = SERIES.map((series) => row[series.key]).sort((a, b) => b - a);
|
||||
const rank = ranked.indexOf(value);
|
||||
const gapUp = rank > 0 ? ranked[rank - 1] - value : Infinity;
|
||||
const gapDown = rank < ranked.length - 1 ? value - ranked[rank + 1] : Infinity;
|
||||
|
||||
return {
|
||||
value,
|
||||
label: { position: gapUp >= gapDown ? 'top' : 'bottom', distance: 9 },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function workingCapitalDaysChart(rows, { width, height }) {
|
||||
return {
|
||||
backgroundColor: '#fff',
|
||||
title: {
|
||||
@@ -20,7 +38,7 @@ export function workingCapitalDaysChart(rows, { width, height }) {
|
||||
text: '泡泡玛特营运资本效率',
|
||||
subtext: '现金转换周期 = 存货周转天数 + 应收周转天数 − 应付周转天数',
|
||||
},
|
||||
legend: { ...legendStyle, top: LAYOUT.legendTop, data: series.map((item) => item.name) },
|
||||
legend: { ...legendStyle, top: LAYOUT.legendTop, data: SERIES.map((item) => item.name) },
|
||||
grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
@@ -37,17 +55,25 @@ export function workingCapitalDaysChart(rows, { width, height }) {
|
||||
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
|
||||
min: 0,
|
||||
},
|
||||
series: series.map((item) => ({
|
||||
series: SERIES.map((item) => ({
|
||||
...lineSeries({
|
||||
name: item.name,
|
||||
data: col(rows, item.key),
|
||||
data: labeledData(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 },
|
||||
label: {
|
||||
show: true,
|
||||
color: item.color,
|
||||
fontFamily: FONT,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
formatter: (p) => `${p.value.toFixed(0)}天`,
|
||||
textBorderColor: '#fff',
|
||||
textBorderWidth: 3,
|
||||
},
|
||||
labelLayout: { hideOverlap: true },
|
||||
z: item.key === 'cash_conversion_cycle' ? 10 : 1,
|
||||
})),
|
||||
graphic: [
|
||||
|
||||
+31
-16
@@ -28,24 +28,34 @@ export const PALETTE = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 试行语义色:柱线组合图中,柱采用低饱和辅助色,折线保留单一强调色。
|
||||
* 待页面视觉确认后再固化为新增图表的正式规范。
|
||||
* 正式色板:轻快消费。
|
||||
* 柱线组合中,柱用不同色相承载分类,折线保留高辨识度的单一强调色。
|
||||
*/
|
||||
export const FINANCIAL_COLORS = {
|
||||
operatingCashFlow: '#6E9A83',
|
||||
investingCashFlow: '#7897B2',
|
||||
financingCashFlow: '#B18A66',
|
||||
netCashIncrease: '#A8465C',
|
||||
netProfit: '#C9885C',
|
||||
cashConversion: '#6B4A6C',
|
||||
operatingCashFlow: '#36A28F',
|
||||
investingCashFlow: '#4D83D1',
|
||||
financingCashFlow: '#EEA64F',
|
||||
netCashIncrease: '#D95567',
|
||||
netProfit: '#F08B5A',
|
||||
cashConversion: '#8256B2',
|
||||
};
|
||||
|
||||
/**
|
||||
* 瀑布图语义色:基准值、正向贡献、负向贡献。
|
||||
* 复用轻快消费财务色板,避免利润率拆解图另起一套颜色。
|
||||
*/
|
||||
export const BRIDGE_COLORS = {
|
||||
total: FINANCIAL_COLORS.investingCashFlow,
|
||||
pos: FINANCIAL_COLORS.operatingCashFlow,
|
||||
neg: FINANCIAL_COLORS.netCashIncrease,
|
||||
};
|
||||
|
||||
export const CHANNEL_COLORS = {
|
||||
retail: '#597A9B',
|
||||
online: '#C87368',
|
||||
robotStore: '#98A77A',
|
||||
wholesale: '#BB9A5C',
|
||||
revenueGrowth: '#775D8B',
|
||||
retail: '#3F7FBE',
|
||||
online: '#E36D63',
|
||||
robotStore: '#71AD81',
|
||||
wholesale: '#E4A349',
|
||||
revenueGrowth: '#8256B2',
|
||||
};
|
||||
|
||||
/** 坐标轴通用样式:只留浅色横向网格,去掉轴线的存在感 */
|
||||
@@ -201,9 +211,12 @@ export function lineSeries({ name, data, color, labelPosition = 'top', formatter
|
||||
|
||||
/**
|
||||
* 柱线组合图的折线标签:正值放在点上方、负值放在点下方;
|
||||
* 若与同期间柱值接近,则加大距离,避免文字压在柱体端点附近。
|
||||
* 若与同期间柱值接近,则加大距离;也可指定将标签放到柱体另一侧,
|
||||
* 避免文字压在柱体端点附近。
|
||||
*/
|
||||
export function lineDataWithSafeLabels(rows, { lineKey, barKeys, distance = 8, closeDistance = 14 }) {
|
||||
export function lineDataWithSafeLabels(rows, {
|
||||
lineKey, barKeys, distance = 8, closeDistance = 14, closePosition,
|
||||
}) {
|
||||
const values = rows.map((row) => row[lineKey]).filter(Number.isFinite);
|
||||
const range = Math.max(...values) - Math.min(...values) || 1;
|
||||
const threshold = range * 0.06;
|
||||
@@ -217,7 +230,9 @@ export function lineDataWithSafeLabels(rows, { lineKey, barKeys, distance = 8, c
|
||||
return {
|
||||
value,
|
||||
label: {
|
||||
position: value < 0 ? 'bottom' : 'top',
|
||||
position: nearestBarGap <= threshold && closePosition
|
||||
? closePosition
|
||||
: (value < 0 ? 'bottom' : 'top'),
|
||||
distance: nearestBarGap <= threshold ? closeDistance : distance,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user