马一丁

Fixed Chart Handling Issues in HTML and PDF and Improved Chart Readability

@@ -24,7 +24,7 @@ try: @@ -24,7 +24,7 @@ try:
24 matplotlib.use('Agg') # 使用非GUI后端 24 matplotlib.use('Agg') # 使用非GUI后端
25 import matplotlib.pyplot as plt 25 import matplotlib.pyplot as plt
26 import matplotlib.font_manager as fm 26 import matplotlib.font_manager as fm
27 - from matplotlib.patches import Wedge 27 + from matplotlib.patches import Wedge, Rectangle
28 import numpy as np 28 import numpy as np
29 MATPLOTLIB_AVAILABLE = True 29 MATPLOTLIB_AVAILABLE = True
30 except ImportError: 30 except ImportError:
@@ -45,24 +45,29 @@ class ChartToSVGConverter: @@ -45,24 +45,29 @@ class ChartToSVGConverter:
45 将Chart.js图表数据转换为SVG矢量图形 45 将Chart.js图表数据转换为SVG矢量图形
46 """ 46 """
47 47
48 - # 默认颜色调色板(与Chart.js默认颜色接近 48 + # 默认颜色调色板(优化版:明亮且易区分
49 DEFAULT_COLORS = [ 49 DEFAULT_COLORS = [
50 - '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',  
51 - '#9966FF', '#FF9F40', '#FF6384', '#C9CBCF' 50 + '#4A90E2', '#E85D75', '#50C878', '#FFB347', # 明亮蓝、珊瑚红、翠绿、橙黄
  51 + '#9B59B6', '#3498DB', '#E67E22', '#16A085', # 紫色、天蓝、橙色、青色
  52 + '#F39C12', '#D35400', '#27AE60', '#8E44AD' # 金色、深橙、绿色、紫罗兰
52 ] 53 ]
53 54
54 - # CSS变量到颜色的映射表(支持常见的Chart.js主题变量 55 + # CSS变量到颜色的映射表(优化版:使用更明亮、更浅的颜色
55 CSS_VAR_COLOR_MAP = { 56 CSS_VAR_COLOR_MAP = {
56 - 'var(--color-accent)': '#007AFF', # 蓝色(强调色)  
57 - 'var(--re-accent-color)': '#007AFF', # 蓝色  
58 - 'var(--color-kpi-down)': '#DC3545', # 红色(下降/危险)  
59 - 'var(--re-danger-color)': '#DC3545', # 红色(危险)  
60 - 'var(--color-warning)': '#FFC107', # 黄色(警告)  
61 - 'var(--re-warning-color)': '#FFC107', # 黄色  
62 - 'var(--color-success)': '#28A745', # 绿色(成功)  
63 - 'var(--re-success-color)': '#28A745', # 绿色  
64 - 'var(--color-primary)': '#007BFF', # 主色  
65 - 'var(--color-secondary)': '#6C757D', # 次要色 57 + 'var(--color-accent)': '#4A90E2', # 明亮蓝色(从#007AFF改为更浅)
  58 + 'var(--re-accent-color)': '#4A90E2', # 明亮蓝色
  59 + 'var(--re-accent-color-translucent)': (0.29, 0.565, 0.886, 0.08), # 蓝色极浅透明 rgba(74, 144, 226, 0.08)
  60 + 'var(--color-kpi-down)': '#E85D75', # 珊瑚红色(从#DC3545改为更柔和)
  61 + 'var(--re-danger-color)': '#E85D75', # 珊瑚红色
  62 + 'var(--re-danger-color-translucent)': (0.91, 0.365, 0.459, 0.08), # 红色极浅透明 rgba(232, 93, 117, 0.08)
  63 + 'var(--color-warning)': '#FFB347', # 柔和橙黄色(从#FFC107改为更浅)
  64 + 'var(--re-warning-color)': '#FFB347', # 柔和橙黄色
  65 + 'var(--re-warning-color-translucent)': (1.0, 0.702, 0.278, 0.08), # 黄色极浅透明 rgba(255, 179, 71, 0.08)
  66 + 'var(--color-success)': '#50C878', # 翠绿色(从#28A745改为更明亮)
  67 + 'var(--re-success-color)': '#50C878', # 翠绿色
  68 + 'var(--re-success-color-translucent)': (0.314, 0.784, 0.471, 0.08), # 绿色极浅透明 rgba(80, 200, 120, 0.08)
  69 + 'var(--color-primary)': '#3498DB', # 天蓝色
  70 + 'var(--color-secondary)': '#95A5A6', # 浅灰色
66 } 71 }
67 72
68 def __init__(self, font_path: Optional[str] = None): 73 def __init__(self, font_path: Optional[str] = None):
@@ -277,7 +282,7 @@ class ChartToSVGConverter: @@ -277,7 +282,7 @@ class ChartToSVGConverter:
277 渲染折线图(增强版) 282 渲染折线图(增强版)
278 283
279 支持特性: 284 支持特性:
280 - - 双y轴(yAxisID: 'y' 和 'y1' 285 + - 多y轴(yAxisID: 'y', 'y1', 'y2', 'y3'...
281 - 填充区域(fill: true) 286 - 填充区域(fill: true)
282 - 透明度(backgroundColor中的alpha通道) 287 - 透明度(backgroundColor中的alpha通道)
283 - 线条样式(tension曲线平滑) 288 - 线条样式(tension曲线平滑)
@@ -289,30 +294,71 @@ class ChartToSVGConverter: @@ -289,30 +294,71 @@ class ChartToSVGConverter:
289 if not labels or not datasets: 294 if not labels or not datasets:
290 return None 295 return None
291 296
292 - # 检查是否有双y轴  
293 - has_dual_axis = any(  
294 - dataset.get('yAxisID') == 'y1' for dataset in datasets  
295 - ) 297 + # 收集所有唯一的yAxisID
  298 + y_axis_ids = []
  299 + for dataset in datasets:
  300 + y_axis_id = dataset.get('yAxisID', 'y')
  301 + if y_axis_id not in y_axis_ids:
  302 + y_axis_ids.append(y_axis_id)
  303 +
  304 + # 确保'y'是第一个轴
  305 + if 'y' in y_axis_ids:
  306 + y_axis_ids.remove('y')
  307 + y_axis_ids.insert(0, 'y')
  308 +
  309 + # 检查是否有多个y轴
  310 + has_multiple_axes = len(y_axis_ids) > 1
296 311
297 title = props.get('title') 312 title = props.get('title')
298 options = props.get('options', {}) 313 options = props.get('options', {})
  314 + scales = options.get('scales', {})
299 315
300 - # 创建图表,如果有双y轴则创建双y轴布局  
301 - if has_dual_axis:  
302 - fig, ax1 = plt.subplots(figsize=(width/dpi, height/dpi), dpi=dpi)  
303 - ax2 = ax1.twinx() # 创建共享x轴的第二个y轴  
304 - else:  
305 - fig, ax1 = self._create_figure(width, height, dpi, title)  
306 - ax2 = None 316 + # 创建图表和多个y轴
  317 + fig, ax1 = plt.subplots(figsize=(width/dpi, height/dpi), dpi=dpi)
307 318
308 - if title and has_dual_axis: 319 + if title:
309 ax1.set_title(title, fontsize=14, fontweight='bold', pad=20) 320 ax1.set_title(title, fontsize=14, fontweight='bold', pad=20)
310 321
  322 + # 创建y轴映射字典
  323 + axes = {'y': ax1}
  324 +
  325 + if has_multiple_axes:
  326 + # 统计每个位置(left/right)的轴数量,用于计算偏移
  327 + left_axes_count = 0
  328 + right_axes_count = 0
  329 +
  330 + # 为每个额外的yAxisID创建新的y轴
  331 + for y_axis_id in y_axis_ids[1:]:
  332 + if y_axis_id == 'y':
  333 + continue
  334 +
  335 + # 创建新的y轴
  336 + new_ax = ax1.twinx()
  337 + axes[y_axis_id] = new_ax
  338 +
  339 + # 从scales配置中获取轴的位置
  340 + y_config = scales.get(y_axis_id, {})
  341 + position = y_config.get('position', 'right')
  342 +
  343 + if position == 'left':
  344 + # 左侧额外轴,向左偏移
  345 + if left_axes_count > 0:
  346 + new_ax.spines['left'].set_position(('outward', 60 * left_axes_count))
  347 + new_ax.yaxis.set_label_position('left')
  348 + new_ax.yaxis.set_ticks_position('left')
  349 + left_axes_count += 1
  350 + else:
  351 + # 右侧额外轴,向右偏移
  352 + if right_axes_count > 0:
  353 + new_ax.spines['right'].set_position(('outward', 60 * right_axes_count))
  354 + right_axes_count += 1
  355 +
311 colors = self._get_colors(datasets) 356 colors = self._get_colors(datasets)
312 357
313 - # 分别收集两个y轴的数据系列  
314 - y1_lines = []  
315 - y2_lines = [] 358 + # 收集每个y轴的线条和填充信息用于图例
  359 + axis_lines = {axis_id: [] for axis_id in y_axis_ids}
  360 + legend_handles = [] # 图例句柄
  361 + legend_labels = [] # 图例标签
316 362
317 # 绘制每个数据系列 363 # 绘制每个数据系列
318 for i, dataset in enumerate(datasets): 364 for i, dataset in enumerate(datasets):
@@ -328,7 +374,7 @@ class ChartToSVGConverter: @@ -328,7 +374,7 @@ class ChartToSVGConverter:
328 background_color = self._parse_color(dataset.get('backgroundColor', color)) 374 background_color = self._parse_color(dataset.get('backgroundColor', color))
329 375
330 # 选择对应的坐标轴 376 # 选择对应的坐标轴
331 - ax = ax2 if (y_axis_id == 'y1' and ax2 is not None) else ax1 377 + ax = axes.get(y_axis_id, ax1)
332 378
333 # 绘制折线 379 # 绘制折线
334 x_data = range(len(labels)) 380 x_data = range(len(labels))
@@ -343,69 +389,79 @@ class ChartToSVGConverter: @@ -343,69 +389,79 @@ class ChartToSVGConverter:
343 y_smooth = spl(x_smooth) 389 y_smooth = spl(x_smooth)
344 line, = ax.plot(x_smooth, y_smooth, label=label, color=border_color, linewidth=2) 390 line, = ax.plot(x_smooth, y_smooth, label=label, color=border_color, linewidth=2)
345 391
346 - # 如果需要填充 392 + # 如果需要填充(使用极低透明度避免遮挡)
347 if fill: 393 if fill:
348 - ax.fill_between(x_smooth, y_smooth, alpha=0.3, color=background_color) 394 + ax.fill_between(x_smooth, y_smooth, alpha=0.08, color=background_color)
349 except: 395 except:
350 # 如果平滑失败,使用普通折线 396 # 如果平滑失败,使用普通折线
351 line, = ax.plot(x_data, dataset_data, marker='o', label=label, 397 line, = ax.plot(x_data, dataset_data, marker='o', label=label,
352 color=border_color, linewidth=2, markersize=6) 398 color=border_color, linewidth=2, markersize=6)
353 if fill: 399 if fill:
354 - ax.fill_between(x_data, dataset_data, alpha=0.3, color=background_color) 400 + ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color)
355 else: 401 else:
356 line, = ax.plot(x_data, dataset_data, marker='o', label=label, 402 line, = ax.plot(x_data, dataset_data, marker='o', label=label,
357 color=border_color, linewidth=2, markersize=6) 403 color=border_color, linewidth=2, markersize=6)
358 if fill: 404 if fill:
359 - ax.fill_between(x_data, dataset_data, alpha=0.3, color=background_color) 405 + ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color)
360 else: 406 else:
361 # 直线连接(tension=0或scipy不可用) 407 # 直线连接(tension=0或scipy不可用)
362 line, = ax.plot(x_data, dataset_data, marker='o', label=label, 408 line, = ax.plot(x_data, dataset_data, marker='o', label=label,
363 color=border_color, linewidth=2, markersize=6) 409 color=border_color, linewidth=2, markersize=6)
364 410
365 - # 如果需要填充 411 + # 如果需要填充(使用极低透明度避免遮挡)
366 if fill: 412 if fill:
367 - ax.fill_between(x_data, dataset_data, alpha=0.3, color=background_color)  
368 -  
369 - # 记录哪个轴有哪些线  
370 - if ax == ax2:  
371 - y2_lines.append(line) 413 + ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color)
  414 +
  415 + # 记录这条线属于哪个轴
  416 + axis_lines[y_axis_id].append(line)
  417 +
  418 + # 创建图例项:如果有填充,创建带填充背景的图例
  419 + if fill:
  420 + # 创建一个矩形patch作为填充背景(使用稍高透明度以便在图例中可见)
  421 + fill_patch = Rectangle((0, 0), 1, 1,
  422 + facecolor=background_color,
  423 + edgecolor='none',
  424 + alpha=0.15)
  425 + # 组合线条和填充patch
  426 + legend_handles.append((line, fill_patch))
  427 + legend_labels.append(label)
372 else: 428 else:
373 - y1_lines.append(line) 429 + legend_handles.append(line)
  430 + legend_labels.append(label)
374 431
375 # 设置x轴标签 432 # 设置x轴标签
376 ax1.set_xticks(range(len(labels))) 433 ax1.set_xticks(range(len(labels)))
377 ax1.set_xticklabels(labels, rotation=45, ha='right') 434 ax1.set_xticklabels(labels, rotation=45, ha='right')
378 435
379 # 设置y轴标签和标题 436 # 设置y轴标签和标题
380 - if has_dual_axis and ax2:  
381 - # 从options中获取y轴配置  
382 - scales = options.get('scales', {})  
383 - y_config = scales.get('y', {})  
384 - y1_config = scales.get('y1', {})  
385 -  
386 - # 设置左侧y轴 437 + for y_axis_id, ax in axes.items():
  438 + y_config = scales.get(y_axis_id, {})
387 y_title = y_config.get('title', {}).get('text', '') 439 y_title = y_config.get('title', {}).get('text', '')
  440 +
388 if y_title: 441 if y_title:
389 - ax1.set_ylabel(y_title, fontsize=11)  
390 -  
391 - # 设置右侧y轴  
392 - y1_title = y1_config.get('title', {}).get('text', '')  
393 - if y1_title:  
394 - ax2.set_ylabel(y1_title, fontsize=11)  
395 -  
396 - # 设置网格(只在主轴显示)  
397 - ax1.grid(True, alpha=0.3, linestyle='--')  
398 - ax2.grid(False) # 右侧y轴不显示网格  
399 -  
400 - # 合并图例(显示所有数据系列)  
401 - lines = y1_lines + y2_lines  
402 - labels_list = [line.get_label() for line in lines]  
403 - ax1.legend(lines, labels_list, loc='best', framealpha=0.9)  
404 - else:  
405 - # 单y轴的情况  
406 - if len(datasets) > 1:  
407 - ax1.legend(loc='best', framealpha=0.9)  
408 - ax1.grid(True, alpha=0.3, linestyle='--') 442 + ax.set_ylabel(y_title, fontsize=11)
  443 +
  444 + # 设置y轴标签颜色(如果该轴只有一条线,使用该线的颜色)
  445 + if len(axis_lines[y_axis_id]) == 1:
  446 + line_color = axis_lines[y_axis_id][0].get_color()
  447 + ax.tick_params(axis='y', labelcolor=line_color)
  448 + ax.yaxis.label.set_color(line_color)
  449 +
  450 + # 设置网格(只在主轴显示)
  451 + ax1.grid(True, alpha=0.3, linestyle='--')
  452 + for y_axis_id in y_axis_ids[1:]:
  453 + if y_axis_id in axes:
  454 + axes[y_axis_id].grid(False)
  455 +
  456 + # 创建图例
  457 + if has_multiple_axes or len(datasets) > 1:
  458 + # 使用自定义的legend_handles和legend_labels
  459 + from matplotlib.legend_handler import HandlerTuple
  460 +
  461 + ax1.legend(legend_handles, legend_labels,
  462 + loc='best',
  463 + framealpha=0.9,
  464 + handler_map={tuple: HandlerTuple(ndivide=None)})
409 465
410 return self._figure_to_svg(fig) 466 return self._figure_to_svg(fig)
411 467