/** * 方木风格主题 —— 统一的配色、字体、坐标轴、水印 * 所有图表共用这一份配置,保证视觉一致性 */ 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 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) }; export const legendStyle = { top: 62, // ⚠️ 已废弃:请改用 LAYOUT.legendTop 或 standardLegend() itemGap: 26, itemWidth: 18, // ❌ 删除 icon: 'roundRect' // 原因:它会覆盖 ECharts 按系列类型自动生成的图例样式(折线图的"短线+空心圆") // 现在让每个图表按需设置,或使用 standardLegend() 获得正确默认值 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, 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, }, }; } /** * 柱状图系列标准配置 * 强制显示所有标签(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; }