/** * 方木风格主题 —— 统一的配色、字体、坐标轴、水印 * 所有图表共用这一份配置,保证视觉一致性 */ 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, }; } /** 底部注释 */ 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; }