/**
* 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,
},
};
}
/**
* tooltip 彩色圆点标记。
*
* 为什么不直接用 ECharts 的默认 marker:折线系列的 visualDrawType 恒为 'fill'
* (Series.js 原型默认值,只有轨迹图 / 箱线图改成 'stroke'),而空心圆折线的
* itemStyle.color 必须是 #fff —— tooltip 取到的 marker 就是白色,在白底提示框上
* 等于隐形。这里按系列语义色自绘标记,不依赖 ECharts 内部取色。
*/
export function tooltipMarker(color) {
return ``;
}
/**
* 标准 tooltip:每一项前面带该系列语义色的圆点。
*
* 不能用 ECharts 默认 marker:折线系列 visualDrawType 为 'fill',
* 而空心圆折线的 itemStyle.color 是 #fff,默认 marker 会被取成白色而在白底上隐形。
* 取色规则:
* - 柱:优先用该数据点自身的颜色(支持逐项着色,如按公司或国别上色)
* - 折线:用 lineStyle.color(空心圆的 itemStyle.color 是白色,不能作语义色)
*
* @param {Array} series 已构造好的系列数组
* @param {(value: number) => string} formatValue 默认数值格式化
* @param {Object string>} [formatBySeries] 按系列名覆盖
* —— 柱线组合图两条序列单位不同(如 GWh 与 %)时必需
*/
export function tooltipOf(series, formatValue, formatBySeries) {
const seriesColors = {};
series.forEach((item) => {
const color = item.lineStyle?.color ?? item.itemStyle?.color;
if (item.name && color) seriesColors[item.name] = color;
});
return {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontFamily: FONT, fontSize: 13 },
formatter: (params) => {
const list = Array.isArray(params) ? params : [params];
if (list.length === 0) return '';
const head = ``
+ `${list[0].axisValueLabel ?? list[0].name ?? ''}`;
const body = list.map((item) => {
const fallback = seriesColors[item.seriesName] ?? PALETTE.grey;
const color = item.seriesType === 'bar' ? (item.color || fallback) : fallback;
const fmt = formatBySeries?.[item.seriesName] ?? formatValue;
const raw = item.value;
const value = raw === null || raw === undefined || raw === '' ? '—' : fmt(raw);
return `${tooltipMarker(color)}${item.seriesName}:${value}`;
});
return [head, ...body].join('
');
},
};
}
/** 底部注释 */
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;
}