Files
smart-fin-chart/js/theme.js
T
2026-09-18 19:03:32 +08:00

409 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Joey 风格主题 —— 统一的配色、字体、坐标轴、水印
* 所有图表共用这一份配置,保证视觉一致性
*/
export const WATERMARK_TEXT = '@Joey知行记';
// 字体名用单引号:SVG 渲染时字符串会被塞进 style="..." 属性,
// 用双引号会提前闭合属性、生成非法 SVG(canvas 渲染看不出来,导出 SVG 时才炸)
export const FONT = "'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif";
/** 调色板:低饱和、克制,接近财经媒体常用的莫兰迪系 */
export const PALETTE = {
brown: '#6b4a2b',
sand: '#d5c7b4',
coral: '#ec7b73',
green: '#2f8a5b',
orange: '#d98324',
purple: '#9b4fa8',
blue: '#3b6ea5',
navy: '#1f4e79',
red: '#b8342a',
sage: '#8aab7e',
ink: '#333333',
grey: '#8c8c8c',
gridLine: '#e8e8e8',
axisLine: '#d0d0d0',
};
/**
* 正式色板:轻快消费。
* 柱线组合中,柱用不同色相承载分类,折线保留高辨识度的单一强调色。
*/
export const FINANCIAL_COLORS = {
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: '#3F7FBE',
online: '#E36D63',
robotStore: '#71AD81',
wholesale: '#E4A349',
revenueGrowth: '#8256B2',
};
/** 坐标轴通用样式:只留浅色横向网格,去掉轴线的存在感 */
export const axisCommon = {
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: PALETTE.grey,
fontSize: 13,
fontFamily: FONT,
},
splitLine: {
show: true,
lineStyle: { color: PALETTE.gridLine, width: 1 },
},
};
/** X 轴:显示轴线,不显示网格 */
export const categoryAxis = {
type: 'category',
boundaryGap: true,
axisLine: { show: true, lineStyle: { color: PALETTE.axisLine } },
axisTick: { show: false },
axisLabel: {
color: PALETTE.ink,
fontSize: 14,
fontFamily: FONT,
margin: 14,
},
splitLine: { show: false },
};
/**
* 期间标签简写:2026H1 → 26H1,2026H2 → 26全年。
* CSV 里保留四位年份(无歧义、便于核对),只在轴上简化显示。
* 非 20xx 开头的标签(如"年初")原样返回。
*/
export function shortPeriod(label) {
return String(label)
.replace(/^20(\d{2})H2$/, '$1全年') // H2 = 全年累计
.replace(/^20(\d{2})/, '$1'); // 其余去掉世纪前缀
}
/** X 轴(期间):在 categoryAxis 基础上套用简写 */
export const periodAxis = {
...categoryAxis,
axisLabel: {
...categoryAxis.axisLabel,
formatter: shortPeriod,
},
};
/**
* 独立期间轴:FY 为全年,H1/H2 为单独半年。
* 用于已拆分累计值的多时间维度图,避免把 H2 错写为全年。
*/
export function shortStandalonePeriod(label) {
return String(label)
.replace(/^20(\d{2})FY$/, '$1全年')
.replace(/^20(\d{2})/, '$1');
}
export const standalonePeriodAxis = {
...categoryAxis,
axisLabel: {
...categoryAxis.axisLabel,
formatter: shortStandalonePeriod,
},
};
export function financialViewLabel(view) {
return ({ all: '全部期间', annual: '年度', halfyear: '半年', quarter: '季度' })[view] || '全部期间';
}
export function financialViewDisclosure(view) {
return '';
}
export const titleStyle = {
left: 'center',
top: 18,
textStyle: {
color: PALETTE.ink,
fontSize: 19,
fontWeight: 'bold',
fontFamily: FONT,
},
subtextStyle: {
color: PALETTE.grey,
fontSize: 13,
fontFamily: FONT,
lineHeight: 20,
},
};
/**
* 标准布局常量
* 经多图表调试验证的标题-图例-绘图区间距,确保视觉呼吸感一致
*/
export const LAYOUT = {
titleTop: 18, // 标题顶部边距(已内置在 titleStyle 中)
legendTop: 85, // 图例顶部边距(副标题到图例约 25px)
gridTop: 145, // 绘图区顶部边距(图例到绘图区约 60px)
};
/** 数值轴整齐上限候选:轴 max 从中取第一个能容纳数据的刻度 */
export const NICE_AXIS_MAX = [20, 25, 50, 60, 80, 100, 120, 150, 200, 250, 300, 400, 500, 600, 800, 1000];
/** 按数据最大值取整齐轴上限;超出候选表尾时按 100 的整数倍向上取整并留 20% 余量 */
export function niceAxisMax(dataMax) {
return NICE_AXIS_MAX.find((max) => max >= dataMax * 1.02)
?? Math.ceil((dataMax * 1.2) / 100) * 100;
}
// 统一图例图标宽度,同时保留 ECharts 按系列类型生成的形状。
export const LEGEND_ITEM_WIDTH = 20;
export const legendStyle = {
top: 62, // ⚠️ 已废弃:请改用 LAYOUT.legendTop 或 standardLegend()
itemGap: 26,
itemWidth: LEGEND_ITEM_WIDTH,
// 不设置 icon:柱状图自动使用矩形,折线图自动使用“短线 + 空心圆”。
textStyle: {
color: PALETTE.ink,
fontSize: 14,
fontFamily: FONT,
},
};
/** 折线标注:白底描边,避免标签被折线自身或柱体穿过时看不清 */
export function labelStyle(color, position = 'top') {
return {
show: true,
position,
color,
fontSize: 13,
fontFamily: FONT,
fontWeight: 500,
distance: 8,
textBorderColor: '#fff',
textBorderWidth: 3,
};
}
/**
* 标准图例配置
* 不设置 icon,让 ECharts 按系列类型自动生成(折线→短线+空心圆,柱状→矩形)
* 统一使用 LAYOUT.legendTop 保证间距一致
*/
export function standardLegend(data, options = {}) {
return {
top: LAYOUT.legendTop,
itemWidth: LEGEND_ITEM_WIDTH,
itemGap: options.itemGap ?? 28,
textStyle: {
color: PALETTE.ink,
fontSize: 14,
fontFamily: FONT,
},
data,
...options, // 允许覆盖任何字段
};
}
/**
* 空心圆点折线系列 —— 标准配置
* 白心 + 彩色边框,视觉统一(symbolSize: 9, borderWidth: 2.5)
*/
export function lineSeries({ name, data, color, labelPosition = 'top', formatter, width = 3 }) {
return {
name,
type: 'line',
data,
smooth: false,
symbol: 'circle',
symbolSize: 9,
itemStyle: {
color: '#fff',
borderColor: color,
borderWidth: 2.5,
},
lineStyle: { color, width },
emphasis: { focus: 'series' },
label: {
...labelStyle(color, labelPosition),
formatter,
},
};
}
/**
* 柱线组合图的折线标签:正值放在点上方、负值放在点下方;
* 若与同期间柱值接近,则加大距离;也可指定将标签放到柱体另一侧,
* 避免文字压在柱体端点附近。
*/
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;
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: nearestBarGap <= threshold && closePosition
? closePosition
: (value < 0 ? 'bottom' : 'top'),
distance: nearestBarGap <= threshold ? closeDistance : distance,
},
};
});
}
/**
* 柱状图系列标准配置
* 强制显示所有标签(overflow: 'none'),避免小柱子标签被 ECharts 自动隐藏
*/
export function barSeries({ name, data, color, barWidth, formatter, ...rest }) {
return {
name,
type: 'bar',
data,
barWidth,
itemStyle: { color },
label: {
show: true,
position: 'top',
fontFamily: FONT,
fontSize: 11,
color: PALETTE.ink,
formatter: formatter ?? ((p) => p.value),
overflow: 'none', // 关键:强制显示所有标签
},
...rest,
};
}
/**
* 归一化堆叠占比图的矮段标签外置偏移。
* 段高不足(< minInsidePx 像素)放不下段内标签时,标签标到柱体右侧;
* 同一根柱里有多个矮段时,柱侧标签自下而上按 gap 像素扇形排开避免重叠
* (先例:ppmt_channel_revenue 的 fanOutsideLabels、ppmt_region_share 的柱侧标签)。
* 返回 { 行序号: { 系列序号: dy } },dy 为 ECharts label.offset 的纵向偏移。
*/
export function fanTinyLabelOffsets(rows, seriesKeys, pxPerUnit, { minInsidePx = 15, gap = 13 } = {}) {
return rows.map((row) => {
const centers = [];
let cum = 0;
seriesKeys.forEach((key) => {
const value = Number.isFinite(row[key]) ? row[key] : 0;
centers.push((cum + value / 2) * pxPerUnit);
cum += value;
});
const tinyIdx = seriesKeys
.map((_, index) => index)
.filter((index) => Number.isFinite(row[seriesKeys[index]])
&& row[seriesKeys[index]] > 0
&& row[seriesKeys[index]] * pxPerUnit < minInsidePx);
if (tinyIdx.length === 0) return {};
const ys = tinyIdx.map((index) => centers[index]);
for (let k = 1; k < ys.length; k += 1) ys[k] = Math.max(ys[k], ys[k - 1] + gap);
if (tinyIdx.length === seriesKeys.length) {
// 整根柱都是矮段:扇形整体对中到柱身中部,避免标签飘离柱顶
const shift = cum * pxPerUnit / 2 - (ys[0] + ys[ys.length - 1]) / 2;
for (let k = 0; k < ys.length; k += 1) ys[k] += shift;
if (ys[0] < 2) {
for (let k = 0; k < ys.length; k += 1) ys[k] += 2 - ys[0];
}
}
const offsets = {};
tinyIdx.forEach((index, k) => { offsets[index] = centers[index] - ys[k]; });
return offsets;
});
}
/**
* 归一化堆叠占比图的逐点标签:段高够时段内显示(用系列的段内配色),
* 矮段外置到柱体右侧(深字白描边),保证每个分类的占比数值都可见。
* 必须配合 fanTinyLabelOffsets 计算的 dy 使用;value 为空或 0 时不渲染该段。
*/
export function stackedSharePoint(value, { pxPerUnit, labelColor, dy = 0, minInsidePx = 15 }) {
if (!Number.isFinite(value) || value === 0) return null;
if (value * pxPerUnit >= minInsidePx) {
return { value, label: { position: 'inside', color: labelColor } };
}
return {
value,
label: {
position: 'right',
color: PALETTE.ink,
distance: 3,
offset: [0, dy],
textBorderColor: '#fff',
textBorderWidth: 2,
},
};
}
/** 底部注释 */
export function footnote(text) {
return {
type: 'text',
left: 'center',
bottom: 12,
style: {
text,
fill: PALETTE.grey,
fontSize: 12,
fontFamily: FONT,
lineHeight: 18,
},
};
}
/**
* 平铺水印。ECharts 没有原生水印,用 graphic 数组铺满画布。
* 旋转 -20°,透明度压到 0.08,不干扰读数。
*/
export function watermarks(width, height, text = WATERMARK_TEXT) {
const items = [];
const stepX = 300;
const stepY = 200;
for (let x = -100; x < width + stepX; x += stepX) {
for (let y = 40; y < height + stepY; y += stepY) {
const offset = (Math.floor(y / stepY) % 2) * (stepX / 2);
items.push({
type: 'text',
left: x + offset,
top: y,
silent: true,
rotation: 0.35,
style: {
text,
fill: 'rgba(120, 100, 80, 0.10)',
fontSize: 20,
fontWeight: 'bold',
fontFamily: FONT,
},
});
}
}
return items;
}