296 lines
7.5 KiB
JavaScript
296 lines
7.5 KiB
JavaScript
/**
|
||
* 方木风格主题 —— 统一的配色、字体、坐标轴、水印
|
||
* 所有图表共用这一份配置,保证视觉一致性
|
||
*/
|
||
|
||
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: '#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 },
|
||
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,
|
||
},
|
||
};
|
||
|
||
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)
|
||
};
|
||
|
||
// 统一图例图标宽度,同时保留 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,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 标准图例配置
|
||
* 不设置 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 }) {
|
||
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 自动隐藏
|
||
*/
|
||
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,
|
||
};
|
||
}
|
||
|
||
/** 底部注释 */
|
||
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;
|
||
}
|