400 lines
11 KiB
JavaScript
400 lines
11 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: '#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)
|
||
};
|
||
|
||
// 统一图例图标宽度,同时保留 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;
|
||
}
|