Standardize financial chart colors and improve label placement

This commit is contained in:
R524809
2026-08-28 17:41:48 +08:00
parent 7aab3d7134
commit e5c21c6148
7 changed files with 114 additions and 61 deletions
+2 -8
View File
@@ -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,
+5 -5
View File
@@ -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 '';
+23 -14
View File
@@ -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,
}),
+40 -14
View File
@@ -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
View File
@@ -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,
},
};