From 994477fd601f44053891332dcf99de196d4cc1ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E4=B8=80=E4=B8=81?= <1769123563@qq.com> Date: Thu, 20 Nov 2025 02:18:52 +0800 Subject: [PATCH] Fixed Chart Handling Issues in HTML and PDF and Improved Chart Readability --- ReportEngine/renderers/chart_to_svg.py | 188 ++++++++++++++++--------- 1 file changed, 122 insertions(+), 66 deletions(-) diff --git a/ReportEngine/renderers/chart_to_svg.py b/ReportEngine/renderers/chart_to_svg.py index 624ba6b..4963397 100644 --- a/ReportEngine/renderers/chart_to_svg.py +++ b/ReportEngine/renderers/chart_to_svg.py @@ -24,7 +24,7 @@ try: matplotlib.use('Agg') # 使用非GUI后端 import matplotlib.pyplot as plt import matplotlib.font_manager as fm - from matplotlib.patches import Wedge + from matplotlib.patches import Wedge, Rectangle import numpy as np MATPLOTLIB_AVAILABLE = True except ImportError: @@ -45,24 +45,29 @@ class ChartToSVGConverter: 将Chart.js图表数据转换为SVG矢量图形 """ - # 默认颜色调色板(与Chart.js默认颜色接近) + # 默认颜色调色板(优化版:明亮且易区分) DEFAULT_COLORS = [ - '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', - '#9966FF', '#FF9F40', '#FF6384', '#C9CBCF' + '#4A90E2', '#E85D75', '#50C878', '#FFB347', # 明亮蓝、珊瑚红、翠绿、橙黄 + '#9B59B6', '#3498DB', '#E67E22', '#16A085', # 紫色、天蓝、橙色、青色 + '#F39C12', '#D35400', '#27AE60', '#8E44AD' # 金色、深橙、绿色、紫罗兰 ] - # CSS变量到颜色的映射表(支持常见的Chart.js主题变量) + # CSS变量到颜色的映射表(优化版:使用更明亮、更浅的颜色) CSS_VAR_COLOR_MAP = { - 'var(--color-accent)': '#007AFF', # 蓝色(强调色) - 'var(--re-accent-color)': '#007AFF', # 蓝色 - 'var(--color-kpi-down)': '#DC3545', # 红色(下降/危险) - 'var(--re-danger-color)': '#DC3545', # 红色(危险) - 'var(--color-warning)': '#FFC107', # 黄色(警告) - 'var(--re-warning-color)': '#FFC107', # 黄色 - 'var(--color-success)': '#28A745', # 绿色(成功) - 'var(--re-success-color)': '#28A745', # 绿色 - 'var(--color-primary)': '#007BFF', # 主色 - 'var(--color-secondary)': '#6C757D', # 次要色 + 'var(--color-accent)': '#4A90E2', # 明亮蓝色(从#007AFF改为更浅) + 'var(--re-accent-color)': '#4A90E2', # 明亮蓝色 + 'var(--re-accent-color-translucent)': (0.29, 0.565, 0.886, 0.08), # 蓝色极浅透明 rgba(74, 144, 226, 0.08) + 'var(--color-kpi-down)': '#E85D75', # 珊瑚红色(从#DC3545改为更柔和) + 'var(--re-danger-color)': '#E85D75', # 珊瑚红色 + 'var(--re-danger-color-translucent)': (0.91, 0.365, 0.459, 0.08), # 红色极浅透明 rgba(232, 93, 117, 0.08) + 'var(--color-warning)': '#FFB347', # 柔和橙黄色(从#FFC107改为更浅) + 'var(--re-warning-color)': '#FFB347', # 柔和橙黄色 + 'var(--re-warning-color-translucent)': (1.0, 0.702, 0.278, 0.08), # 黄色极浅透明 rgba(255, 179, 71, 0.08) + 'var(--color-success)': '#50C878', # 翠绿色(从#28A745改为更明亮) + 'var(--re-success-color)': '#50C878', # 翠绿色 + 'var(--re-success-color-translucent)': (0.314, 0.784, 0.471, 0.08), # 绿色极浅透明 rgba(80, 200, 120, 0.08) + 'var(--color-primary)': '#3498DB', # 天蓝色 + 'var(--color-secondary)': '#95A5A6', # 浅灰色 } def __init__(self, font_path: Optional[str] = None): @@ -277,7 +282,7 @@ class ChartToSVGConverter: 渲染折线图(增强版) 支持特性: - - 双y轴(yAxisID: 'y' 和 'y1') + - 多y轴(yAxisID: 'y', 'y1', 'y2', 'y3'...) - 填充区域(fill: true) - 透明度(backgroundColor中的alpha通道) - 线条样式(tension曲线平滑) @@ -289,30 +294,71 @@ class ChartToSVGConverter: if not labels or not datasets: return None - # 检查是否有双y轴 - has_dual_axis = any( - dataset.get('yAxisID') == 'y1' for dataset in datasets - ) + # 收集所有唯一的yAxisID + y_axis_ids = [] + for dataset in datasets: + y_axis_id = dataset.get('yAxisID', 'y') + if y_axis_id not in y_axis_ids: + y_axis_ids.append(y_axis_id) + + # 确保'y'是第一个轴 + if 'y' in y_axis_ids: + y_axis_ids.remove('y') + y_axis_ids.insert(0, 'y') + + # 检查是否有多个y轴 + has_multiple_axes = len(y_axis_ids) > 1 title = props.get('title') options = props.get('options', {}) + scales = options.get('scales', {}) - # 创建图表,如果有双y轴则创建双y轴布局 - if has_dual_axis: - fig, ax1 = plt.subplots(figsize=(width/dpi, height/dpi), dpi=dpi) - ax2 = ax1.twinx() # 创建共享x轴的第二个y轴 - else: - fig, ax1 = self._create_figure(width, height, dpi, title) - ax2 = None + # 创建图表和多个y轴 + fig, ax1 = plt.subplots(figsize=(width/dpi, height/dpi), dpi=dpi) - if title and has_dual_axis: + if title: ax1.set_title(title, fontsize=14, fontweight='bold', pad=20) + # 创建y轴映射字典 + axes = {'y': ax1} + + if has_multiple_axes: + # 统计每个位置(left/right)的轴数量,用于计算偏移 + left_axes_count = 0 + right_axes_count = 0 + + # 为每个额外的yAxisID创建新的y轴 + for y_axis_id in y_axis_ids[1:]: + if y_axis_id == 'y': + continue + + # 创建新的y轴 + new_ax = ax1.twinx() + axes[y_axis_id] = new_ax + + # 从scales配置中获取轴的位置 + y_config = scales.get(y_axis_id, {}) + position = y_config.get('position', 'right') + + if position == 'left': + # 左侧额外轴,向左偏移 + if left_axes_count > 0: + new_ax.spines['left'].set_position(('outward', 60 * left_axes_count)) + new_ax.yaxis.set_label_position('left') + new_ax.yaxis.set_ticks_position('left') + left_axes_count += 1 + else: + # 右侧额外轴,向右偏移 + if right_axes_count > 0: + new_ax.spines['right'].set_position(('outward', 60 * right_axes_count)) + right_axes_count += 1 + colors = self._get_colors(datasets) - # 分别收集两个y轴的数据系列 - y1_lines = [] - y2_lines = [] + # 收集每个y轴的线条和填充信息用于图例 + axis_lines = {axis_id: [] for axis_id in y_axis_ids} + legend_handles = [] # 图例句柄 + legend_labels = [] # 图例标签 # 绘制每个数据系列 for i, dataset in enumerate(datasets): @@ -328,7 +374,7 @@ class ChartToSVGConverter: background_color = self._parse_color(dataset.get('backgroundColor', color)) # 选择对应的坐标轴 - ax = ax2 if (y_axis_id == 'y1' and ax2 is not None) else ax1 + ax = axes.get(y_axis_id, ax1) # 绘制折线 x_data = range(len(labels)) @@ -343,69 +389,79 @@ class ChartToSVGConverter: y_smooth = spl(x_smooth) line, = ax.plot(x_smooth, y_smooth, label=label, color=border_color, linewidth=2) - # 如果需要填充 + # 如果需要填充(使用极低透明度避免遮挡) if fill: - ax.fill_between(x_smooth, y_smooth, alpha=0.3, color=background_color) + ax.fill_between(x_smooth, y_smooth, alpha=0.08, color=background_color) except: # 如果平滑失败,使用普通折线 line, = ax.plot(x_data, dataset_data, marker='o', label=label, color=border_color, linewidth=2, markersize=6) if fill: - ax.fill_between(x_data, dataset_data, alpha=0.3, color=background_color) + ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color) else: line, = ax.plot(x_data, dataset_data, marker='o', label=label, color=border_color, linewidth=2, markersize=6) if fill: - ax.fill_between(x_data, dataset_data, alpha=0.3, color=background_color) + ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color) else: # 直线连接(tension=0或scipy不可用) line, = ax.plot(x_data, dataset_data, marker='o', label=label, color=border_color, linewidth=2, markersize=6) - # 如果需要填充 + # 如果需要填充(使用极低透明度避免遮挡) if fill: - ax.fill_between(x_data, dataset_data, alpha=0.3, color=background_color) + ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color) - # 记录哪个轴有哪些线 - if ax == ax2: - y2_lines.append(line) + # 记录这条线属于哪个轴 + axis_lines[y_axis_id].append(line) + + # 创建图例项:如果有填充,创建带填充背景的图例 + if fill: + # 创建一个矩形patch作为填充背景(使用稍高透明度以便在图例中可见) + fill_patch = Rectangle((0, 0), 1, 1, + facecolor=background_color, + edgecolor='none', + alpha=0.15) + # 组合线条和填充patch + legend_handles.append((line, fill_patch)) + legend_labels.append(label) else: - y1_lines.append(line) + legend_handles.append(line) + legend_labels.append(label) # 设置x轴标签 ax1.set_xticks(range(len(labels))) ax1.set_xticklabels(labels, rotation=45, ha='right') # 设置y轴标签和标题 - if has_dual_axis and ax2: - # 从options中获取y轴配置 - scales = options.get('scales', {}) - y_config = scales.get('y', {}) - y1_config = scales.get('y1', {}) - - # 设置左侧y轴 + for y_axis_id, ax in axes.items(): + y_config = scales.get(y_axis_id, {}) y_title = y_config.get('title', {}).get('text', '') + if y_title: - ax1.set_ylabel(y_title, fontsize=11) + ax.set_ylabel(y_title, fontsize=11) - # 设置右侧y轴 - y1_title = y1_config.get('title', {}).get('text', '') - if y1_title: - ax2.set_ylabel(y1_title, fontsize=11) + # 设置y轴标签颜色(如果该轴只有一条线,使用该线的颜色) + if len(axis_lines[y_axis_id]) == 1: + line_color = axis_lines[y_axis_id][0].get_color() + ax.tick_params(axis='y', labelcolor=line_color) + ax.yaxis.label.set_color(line_color) - # 设置网格(只在主轴显示) - ax1.grid(True, alpha=0.3, linestyle='--') - ax2.grid(False) # 右侧y轴不显示网格 + # 设置网格(只在主轴显示) + ax1.grid(True, alpha=0.3, linestyle='--') + for y_axis_id in y_axis_ids[1:]: + if y_axis_id in axes: + axes[y_axis_id].grid(False) - # 合并图例(显示所有数据系列) - lines = y1_lines + y2_lines - labels_list = [line.get_label() for line in lines] - ax1.legend(lines, labels_list, loc='best', framealpha=0.9) - else: - # 单y轴的情况 - if len(datasets) > 1: - ax1.legend(loc='best', framealpha=0.9) - ax1.grid(True, alpha=0.3, linestyle='--') + # 创建图例 + if has_multiple_axes or len(datasets) > 1: + # 使用自定义的legend_handles和legend_labels + from matplotlib.legend_handler import HandlerTuple + + ax1.legend(legend_handles, legend_labels, + loc='best', + framealpha=0.9, + handler_map={tuple: HandlerTuple(ndivide=None)}) return self._figure_to_svg(fig)