1086 lines
39 KiB
Python
1086 lines
39 KiB
Python
"""
|
||
图表到SVG转换器 - 将Chart.js数据转换为矢量SVG图形
|
||
|
||
支持的图表类型:
|
||
- line: 折线图
|
||
- bar: 柱状图
|
||
- pie: 饼图
|
||
- doughnut: 圆环图
|
||
- radar: 雷达图
|
||
- polarArea: 极地区域图
|
||
- scatter: 散点图
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import io
|
||
import re
|
||
from datetime import datetime
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
from loguru import logger
|
||
|
||
try:
|
||
import matplotlib
|
||
matplotlib.use('Agg') # 使用非GUI后端
|
||
import matplotlib.pyplot as plt
|
||
import matplotlib.dates as mdates
|
||
import matplotlib.font_manager as fm
|
||
from matplotlib.patches import Wedge, Rectangle
|
||
import numpy as np
|
||
MATPLOTLIB_AVAILABLE = True
|
||
except ImportError:
|
||
MATPLOTLIB_AVAILABLE = False
|
||
logger.warning("Matplotlib未安装,PDF图表矢量渲染功能将不可用")
|
||
|
||
# 可选依赖:scipy用于曲线平滑
|
||
try:
|
||
from scipy.interpolate import make_interp_spline
|
||
SCIPY_AVAILABLE = True
|
||
except ImportError:
|
||
SCIPY_AVAILABLE = False
|
||
logger.info("Scipy未安装,折线图将不支持曲线平滑功能(不影响基本渲染)")
|
||
|
||
|
||
class ChartToSVGConverter:
|
||
"""
|
||
将Chart.js图表数据转换为SVG矢量图形
|
||
"""
|
||
|
||
# 默认颜色调色板(优化版:明亮且易区分)
|
||
DEFAULT_COLORS = [
|
||
'#4A90E2', '#E85D75', '#50C878', '#FFB347', # 明亮蓝、珊瑚红、翠绿、橙黄
|
||
'#9B59B6', '#3498DB', '#E67E22', '#16A085', # 紫色、天蓝、橙色、青色
|
||
'#F39C12', '#D35400', '#27AE60', '#8E44AD' # 金色、深橙、绿色、紫罗兰
|
||
]
|
||
|
||
# CSS变量到颜色的映射表(优化版:使用更明亮、更浅的颜色)
|
||
CSS_VAR_COLOR_MAP = {
|
||
'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-accent-positive)': '#50C878',
|
||
'var(--color-accent-negative)': '#E85D75',
|
||
'var(--color-text-secondary)': '#6B7280',
|
||
'var(--accentPositive)': '#50C878',
|
||
'var(--accentNegative)': '#E85D75',
|
||
'var(--sentiment-positive, #28A745)': '#28A745',
|
||
'var(--sentiment-negative, #E53E3E)': '#E53E3E',
|
||
'var(--sentiment-neutral, #FFC107)': '#FFC107',
|
||
'var(--sentiment-positive)': '#28A745',
|
||
'var(--sentiment-negative)': '#E53E3E',
|
||
'var(--sentiment-neutral)': '#FFC107',
|
||
'var(--color-primary)': '#3498DB', # 天蓝色
|
||
'var(--color-secondary)': '#95A5A6', # 浅灰色
|
||
}
|
||
|
||
# 支持解析 rgba(var(--color-primary-rgb), 0.5) 这类格式的兜底映射
|
||
CSS_VAR_RGB_MAP = {
|
||
'color-primary-rgb': (52, 152, 219),
|
||
'color-tone-up-rgb': (80, 200, 120),
|
||
'color-tone-down-rgb': (232, 93, 117),
|
||
'color-accent-positive-rgb': (80, 200, 120),
|
||
'color-accent-neutral-rgb': (149, 165, 166),
|
||
}
|
||
|
||
def __init__(self, font_path: Optional[str] = None):
|
||
"""
|
||
初始化转换器
|
||
|
||
参数:
|
||
font_path: 中文字体路径(可选)
|
||
"""
|
||
if not MATPLOTLIB_AVAILABLE:
|
||
raise RuntimeError("Matplotlib未安装,请运行: pip install matplotlib")
|
||
|
||
self.font_path = font_path
|
||
self._setup_chinese_font()
|
||
|
||
def _setup_chinese_font(self):
|
||
"""配置中文字体"""
|
||
if self.font_path:
|
||
try:
|
||
# 添加自定义字体
|
||
fm.fontManager.addfont(self.font_path)
|
||
# 设置默认字体
|
||
font_prop = fm.FontProperties(fname=self.font_path)
|
||
plt.rcParams['font.family'] = font_prop.get_name()
|
||
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
|
||
logger.info(f"已加载中文字体: {self.font_path}")
|
||
except Exception as e:
|
||
logger.warning(f"加载中文字体失败: {e},将使用系统默认字体")
|
||
else:
|
||
# 尝试使用系统中文字体
|
||
try:
|
||
plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS', 'DejaVu Sans']
|
||
plt.rcParams['axes.unicode_minus'] = False
|
||
except Exception as e:
|
||
logger.warning(f"配置中文字体失败: {e}")
|
||
|
||
def convert_widget_to_svg(
|
||
self,
|
||
widget_data: Dict[str, Any],
|
||
width: int = 800,
|
||
height: int = 500,
|
||
dpi: int = 100
|
||
) -> Optional[str]:
|
||
"""
|
||
将widget数据转换为SVG字符串
|
||
|
||
参数:
|
||
widget_data: widget块数据(包含widgetType、data、props)
|
||
width: 图表宽度(像素)
|
||
height: 图表高度(像素)
|
||
dpi: DPI设置
|
||
|
||
返回:
|
||
str: SVG字符串,失败返回None
|
||
"""
|
||
try:
|
||
# 提取图表类型
|
||
widget_type = widget_data.get('widgetType', '')
|
||
if not widget_type or not widget_type.startswith('chart.js'):
|
||
logger.warning(f"不支持的widget类型: {widget_type}")
|
||
return None
|
||
|
||
# 从widgetType中提取图表类型,例如 "chart.js/line" -> "line"
|
||
chart_type = widget_type.split('/')[-1] if '/' in widget_type else 'bar'
|
||
|
||
# 也检查props中的type
|
||
props = widget_data.get('props', {})
|
||
if props.get('type'):
|
||
chart_type = props['type']
|
||
|
||
# 提取数据
|
||
data = widget_data.get('data', {})
|
||
if not data:
|
||
logger.warning("图表数据为空")
|
||
return None
|
||
|
||
# 根据图表类型调用相应的渲染方法
|
||
render_method = getattr(self, f'_render_{chart_type}', None)
|
||
if not render_method:
|
||
logger.warning(f"不支持的图表类型: {chart_type}")
|
||
return None
|
||
|
||
# 创建图表并转换为SVG
|
||
return render_method(data, props, width, height, dpi)
|
||
|
||
except Exception as e:
|
||
logger.error(f"转换图表为SVG失败: {e}", exc_info=True)
|
||
return None
|
||
|
||
def _create_figure(
|
||
self,
|
||
width: int,
|
||
height: int,
|
||
dpi: int,
|
||
title: Optional[str] = None
|
||
) -> Tuple[Any, Any]:
|
||
"""
|
||
创建matplotlib图表
|
||
|
||
返回:
|
||
tuple: (fig, ax)
|
||
"""
|
||
fig, ax = plt.subplots(figsize=(width/dpi, height/dpi), dpi=dpi)
|
||
|
||
if title:
|
||
ax.set_title(title, fontsize=14, fontweight='bold', pad=20)
|
||
|
||
return fig, ax
|
||
|
||
def _parse_color(self, color: Any) -> Any:
|
||
"""
|
||
解析颜色值,将CSS格式转换为matplotlib支持的格式
|
||
|
||
参数:
|
||
color: 颜色值(可能是CSS格式如rgba()或十六进制或CSS变量)
|
||
|
||
返回:
|
||
matplotlib支持的颜色格式(hex字符串或RGB(A)元组)
|
||
"""
|
||
if color is None:
|
||
return None
|
||
|
||
# 处理numpy数组,统一转为原生列表
|
||
_np = globals().get("np")
|
||
if _np is not None and hasattr(_np, "ndarray") and isinstance(color, _np.ndarray):
|
||
color = color.tolist()
|
||
|
||
# 直接透传已经是序列的颜色(如 (r,g,b,a)),避免被转成字符串后失效
|
||
if isinstance(color, (list, tuple)):
|
||
if len(color) in (3, 4) and all(isinstance(c, (int, float)) for c in color):
|
||
normalized = []
|
||
for idx, channel in enumerate(color):
|
||
# Matplotlib接受0-1之间的浮点数,若值>1则按0-255来源归一化
|
||
value = float(channel)
|
||
if value > 1:
|
||
value = value / 255.0
|
||
# 只对RGB通道做强制裁剪,alpha按0-1裁剪
|
||
if idx < 3:
|
||
value = max(0.0, min(value, 1.0))
|
||
else:
|
||
value = max(0.0, min(value, 1.0))
|
||
normalized.append(value)
|
||
return tuple(normalized)
|
||
|
||
try:
|
||
return tuple(color)
|
||
except Exception:
|
||
return color
|
||
|
||
# 其余非字符串类型保持原有字符串回退策略
|
||
if not isinstance(color, str):
|
||
return str(color)
|
||
|
||
color = color.strip()
|
||
|
||
# 处理 rgba(var(--color-primary-rgb), 0.5) / rgb(var(--color-primary-rgb))
|
||
var_rgba_pattern = r'rgba?\(var\(--([\w-]+)\)\s*(?:,\s*([\d.]+))?\)'
|
||
match = re.match(var_rgba_pattern, color)
|
||
if match:
|
||
var_name, alpha_str = match.groups()
|
||
rgb_tuple = self.CSS_VAR_RGB_MAP.get(var_name)
|
||
|
||
# 兼容缺少 -rgb 后缀的写法
|
||
if not rgb_tuple:
|
||
if var_name.endswith('-rgb'):
|
||
rgb_tuple = self.CSS_VAR_RGB_MAP.get(var_name[:-4])
|
||
else:
|
||
rgb_tuple = self.CSS_VAR_RGB_MAP.get(f"{var_name}-rgb")
|
||
|
||
if rgb_tuple:
|
||
r, g, b = rgb_tuple
|
||
alpha = float(alpha_str) if alpha_str is not None else 1.0
|
||
return (r / 255, g / 255, b / 255, alpha)
|
||
|
||
# 【增强】处理CSS变量,例如 var(--color-accent)
|
||
# 使用预定义的颜色映射表替代CSS变量,确保不同变量有不同的颜色
|
||
if color.startswith('var('):
|
||
# 解析 var(--token, fallback) 形式
|
||
fb_match = re.match(r'^var\(\s*--[^,)+]+,\s*([^)]+)\)', color)
|
||
if fb_match:
|
||
fb_raw = fb_match.group(1).strip()
|
||
fb_color = self._parse_color(fb_raw)
|
||
if fb_color:
|
||
return fb_color
|
||
# 尝试从映射表中查找对应的颜色
|
||
mapped_color = self.CSS_VAR_COLOR_MAP.get(color)
|
||
if mapped_color:
|
||
return mapped_color
|
||
# 如果映射表中没有,尝试从变量名推断颜色类型
|
||
if 'accent' in color or 'primary' in color:
|
||
return '#007AFF' # 蓝色
|
||
elif 'danger' in color or 'down' in color or 'error' in color:
|
||
return '#DC3545' # 红色
|
||
elif 'warning' in color:
|
||
return '#FFC107' # 黄色
|
||
elif 'success' in color or 'up' in color:
|
||
return '#28A745' # 绿色
|
||
# 默认返回蓝色
|
||
return '#36A2EB'
|
||
|
||
# 处理rgba(r, g, b, a)格式
|
||
rgba_pattern = r'rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)'
|
||
match = re.match(rgba_pattern, color)
|
||
if match:
|
||
r, g, b, a = match.groups()
|
||
# 转换为matplotlib格式 (r/255, g/255, b/255, a)
|
||
return (int(r)/255, int(g)/255, int(b)/255, float(a))
|
||
|
||
# 处理rgb(r, g, b)格式
|
||
rgb_pattern = r'rgb\((\d+),\s*(\d+),\s*(\d+)\)'
|
||
match = re.match(rgb_pattern, color)
|
||
if match:
|
||
r, g, b = match.groups()
|
||
# 转换为matplotlib格式 (r/255, g/255, b/255)
|
||
return (int(r)/255, int(g)/255, int(b)/255)
|
||
|
||
# 其他格式(十六进制、颜色名等)直接返回
|
||
return color
|
||
|
||
def _ensure_visible_color(self, color: Any, fallback: str, min_alpha: float = 0.6) -> Any:
|
||
"""
|
||
确保颜色在渲染时可见:避免透明值并提升过低的不透明度
|
||
"""
|
||
base_color = fallback if color in (None, "", "transparent") else color
|
||
parsed = self._parse_color(base_color)
|
||
fallback_parsed = self._parse_color(fallback)
|
||
|
||
if isinstance(parsed, tuple):
|
||
if len(parsed) == 4:
|
||
r, g, b, a = parsed
|
||
return (r, g, b, max(a, min_alpha))
|
||
return parsed
|
||
|
||
if isinstance(parsed, str) and parsed.lower() == "transparent":
|
||
return fallback_parsed
|
||
|
||
return parsed if parsed is not None else fallback_parsed
|
||
|
||
def _get_colors(self, datasets: List[Dict[str, Any]]) -> List[str]:
|
||
"""
|
||
获取图表颜色
|
||
|
||
优先使用dataset中定义的颜色,否则使用默认调色板
|
||
"""
|
||
colors = []
|
||
for i, dataset in enumerate(datasets):
|
||
# 尝试获取各种可能的颜色字段
|
||
color = (
|
||
dataset.get('backgroundColor') or
|
||
dataset.get('borderColor') or
|
||
dataset.get('color') or
|
||
self.DEFAULT_COLORS[i % len(self.DEFAULT_COLORS)]
|
||
)
|
||
|
||
# 如果是颜色数组,取第一个
|
||
if isinstance(color, list):
|
||
color = color[0] if color else self.DEFAULT_COLORS[i % len(self.DEFAULT_COLORS)]
|
||
|
||
# 解析颜色格式
|
||
color = self._parse_color(color)
|
||
|
||
colors.append(color)
|
||
|
||
return colors
|
||
|
||
def _align_labels_and_data(
|
||
self,
|
||
labels: Any,
|
||
dataset_data: Any,
|
||
chart_type: str,
|
||
require_positive_sum: bool = False
|
||
) -> Tuple[List[str], List[float]]:
|
||
"""
|
||
对齐类别型图表的标签与数据长度,并清理非数值值。
|
||
|
||
Matplotlib的饼图/圆环图要求labels与数据长度一致,否则会抛出错误。
|
||
"""
|
||
original_label_len = len(labels) if isinstance(labels, list) else 0
|
||
original_data_len = len(dataset_data) if isinstance(dataset_data, list) else 0
|
||
|
||
aligned_labels = [str(label) for label in labels] if isinstance(labels, list) else []
|
||
raw_data = dataset_data if isinstance(dataset_data, list) else []
|
||
|
||
cleaned_data: List[float] = []
|
||
for value in raw_data:
|
||
try:
|
||
numeric = float(value) if value is not None else 0.0
|
||
except (TypeError, ValueError):
|
||
numeric = 0.0
|
||
if numeric < 0:
|
||
numeric = 0.0
|
||
cleaned_data.append(numeric)
|
||
|
||
target_len = max(len(aligned_labels), len(cleaned_data))
|
||
if target_len == 0:
|
||
return [], []
|
||
|
||
if len(aligned_labels) < target_len:
|
||
start = len(aligned_labels)
|
||
aligned_labels.extend([f"未命名{start + idx + 1}" for idx in range(target_len - start)])
|
||
|
||
if len(cleaned_data) < target_len:
|
||
cleaned_data.extend([0.0] * (target_len - len(cleaned_data)))
|
||
|
||
if original_label_len != original_data_len:
|
||
logger.warning(
|
||
f"{chart_type}图labels长度({original_label_len})与data长度({original_data_len})不一致,"
|
||
f"已对齐为{target_len}"
|
||
)
|
||
|
||
if require_positive_sum and not any(value > 0 for value in cleaned_data):
|
||
logger.warning(f"{chart_type}图数据为空,跳过渲染")
|
||
return [], []
|
||
|
||
return aligned_labels[:target_len], cleaned_data[:target_len]
|
||
|
||
def _figure_to_svg(self, fig: Any) -> str:
|
||
"""
|
||
将matplotlib图表转换为SVG字符串
|
||
"""
|
||
svg_buffer = io.BytesIO()
|
||
fig.savefig(svg_buffer, format='svg', bbox_inches='tight', transparent=False, facecolor='white')
|
||
plt.close(fig)
|
||
|
||
svg_buffer.seek(0)
|
||
svg_string = svg_buffer.getvalue().decode('utf-8')
|
||
|
||
return svg_string
|
||
|
||
def _render_line(
|
||
self,
|
||
data: Dict[str, Any],
|
||
props: Dict[str, Any],
|
||
width: int,
|
||
height: int,
|
||
dpi: int
|
||
) -> Optional[str]:
|
||
"""
|
||
渲染折线图(增强版)
|
||
|
||
支持特性:
|
||
- 多y轴(yAxisID: 'y', 'y1', 'y2', 'y3'...)
|
||
- 填充区域(fill: true)
|
||
- 透明度(backgroundColor中的alpha通道)
|
||
- 线条样式(tension曲线平滑)
|
||
"""
|
||
try:
|
||
labels = data.get('labels') or []
|
||
datasets = data.get('datasets') or []
|
||
|
||
has_object_points = any(
|
||
isinstance(ds, dict)
|
||
and isinstance(ds.get('data'), list)
|
||
and any(isinstance(pt, dict) and ('x' in pt or 'y' in pt) for pt in ds.get('data'))
|
||
for ds in datasets
|
||
)
|
||
|
||
if (not datasets) or ((not labels) and not has_object_points):
|
||
return None
|
||
|
||
# 收集所有唯一的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', {})
|
||
x_tick_labels = list(labels) if isinstance(labels, list) else []
|
||
|
||
# 创建图表和多个y轴
|
||
fig, ax1 = plt.subplots(figsize=(width/dpi, height/dpi), dpi=dpi)
|
||
|
||
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轴的线条和填充信息用于图例
|
||
axis_lines = {axis_id: [] for axis_id in y_axis_ids}
|
||
legend_handles = [] # 图例句柄
|
||
legend_labels = [] # 图例标签
|
||
|
||
# 绘制每个数据系列
|
||
for i, dataset in enumerate(datasets):
|
||
dataset_data = dataset.get('data', [])
|
||
label = dataset.get('label', f'系列{i+1}')
|
||
color = colors[i]
|
||
|
||
# 获取配置
|
||
y_axis_id = dataset.get('yAxisID', 'y')
|
||
fill = True # 强制开启填充,便于对比
|
||
tension = dataset.get('tension', 0) # 0表示直线,0.4表示平滑曲线
|
||
border_color = self._parse_color(dataset.get('borderColor', color))
|
||
background_color = self._parse_color(dataset.get('backgroundColor', color))
|
||
|
||
# 选择对应的坐标轴
|
||
ax = axes.get(y_axis_id, ax1)
|
||
|
||
is_object_data = isinstance(dataset_data, list) and any(
|
||
isinstance(point, dict) and ('x' in point or 'y' in point)
|
||
for point in dataset_data
|
||
)
|
||
|
||
if is_object_data:
|
||
x_data = []
|
||
y_data = []
|
||
annotations = []
|
||
|
||
for idx, point in enumerate(dataset_data):
|
||
if not isinstance(point, dict):
|
||
continue
|
||
|
||
label_text = str(point.get('x', f"点{idx + 1}"))
|
||
if len(x_tick_labels) < len(dataset_data):
|
||
x_tick_labels.append(label_text)
|
||
|
||
x_data.append(len(x_data))
|
||
|
||
y_val = point.get('y', 0)
|
||
try:
|
||
y_val = float(y_val)
|
||
except (TypeError, ValueError):
|
||
y_val = 0
|
||
y_data.append(y_val)
|
||
annotations.append(point.get('event'))
|
||
|
||
if not x_data:
|
||
continue
|
||
|
||
line, = ax.plot(x_data, y_data, marker='o', label=label,
|
||
color=border_color, linewidth=2, markersize=6)
|
||
|
||
if fill:
|
||
ax.fill_between(x_data, y_data, alpha=0.2, color=background_color)
|
||
|
||
for pos, y_val, text in zip(x_data, y_data, annotations):
|
||
if text:
|
||
ax.annotate(
|
||
text,
|
||
(pos, y_val),
|
||
textcoords='offset points',
|
||
xytext=(0, 8),
|
||
ha='center',
|
||
fontsize=8,
|
||
rotation=20
|
||
)
|
||
else:
|
||
# 绘制折线
|
||
x_data = range(len(labels))
|
||
|
||
# 根据tension值决定是否平滑
|
||
if tension > 0 and SCIPY_AVAILABLE:
|
||
# 使用样条插值平滑曲线(需要scipy)
|
||
if len(dataset_data) >= 4: # 至少需要4个点才能平滑
|
||
try:
|
||
x_smooth = np.linspace(0, len(labels)-1, len(labels)*3)
|
||
spl = make_interp_spline(x_data, dataset_data, k=min(3, len(dataset_data)-1))
|
||
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.2, 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.2, 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.2, 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.2, color=background_color)
|
||
|
||
# 记录这条线属于哪个轴
|
||
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:
|
||
legend_handles.append(line)
|
||
legend_labels.append(label)
|
||
|
||
# 设置x轴标签
|
||
if x_tick_labels:
|
||
ax1.set_xticks(range(len(x_tick_labels)))
|
||
ax1.set_xticklabels(x_tick_labels, rotation=45, ha='right')
|
||
|
||
# 设置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:
|
||
ax.set_ylabel(y_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='--')
|
||
for y_axis_id in y_axis_ids[1:]:
|
||
if y_axis_id in axes:
|
||
axes[y_axis_id].grid(False)
|
||
|
||
# 创建图例
|
||
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)
|
||
|
||
except Exception as e:
|
||
logger.error(f"渲染折线图失败: {e}", exc_info=True)
|
||
return None
|
||
|
||
def _render_bar(
|
||
self,
|
||
data: Dict[str, Any],
|
||
props: Dict[str, Any],
|
||
width: int,
|
||
height: int,
|
||
dpi: int
|
||
) -> Optional[str]:
|
||
"""渲染柱状图"""
|
||
try:
|
||
labels = data.get('labels', [])
|
||
datasets = data.get('datasets', [])
|
||
|
||
if not labels or not datasets:
|
||
return None
|
||
|
||
title = props.get('title')
|
||
fig, ax = self._create_figure(width, height, dpi, title)
|
||
|
||
colors = self._get_colors(datasets)
|
||
|
||
# 计算柱子位置
|
||
x = np.arange(len(labels))
|
||
width_bar = 0.8 / len(datasets) if len(datasets) > 1 else 0.6
|
||
|
||
# 绘制每个数据系列
|
||
for i, dataset in enumerate(datasets):
|
||
dataset_data = dataset.get('data', [])
|
||
label = dataset.get('label', f'系列{i+1}')
|
||
color = colors[i]
|
||
|
||
offset = (i - len(datasets)/2 + 0.5) * width_bar
|
||
ax.bar(
|
||
x + offset,
|
||
dataset_data,
|
||
width_bar,
|
||
label=label,
|
||
color=color,
|
||
alpha=0.8,
|
||
edgecolor='white',
|
||
linewidth=0.5
|
||
)
|
||
|
||
# 设置x轴标签
|
||
ax.set_xticks(x)
|
||
ax.set_xticklabels(labels, rotation=45, ha='right')
|
||
|
||
# 显示图例
|
||
if len(datasets) > 1:
|
||
ax.legend(loc='best', framealpha=0.9)
|
||
|
||
# 网格
|
||
ax.grid(True, alpha=0.3, linestyle='--', axis='y')
|
||
|
||
return self._figure_to_svg(fig)
|
||
|
||
except Exception as e:
|
||
logger.error(f"渲染柱状图失败: {e}")
|
||
return None
|
||
|
||
def _render_pie(
|
||
self,
|
||
data: Dict[str, Any],
|
||
props: Dict[str, Any],
|
||
width: int,
|
||
height: int,
|
||
dpi: int
|
||
) -> Optional[str]:
|
||
"""渲染饼图"""
|
||
try:
|
||
labels = data.get('labels', [])
|
||
datasets = data.get('datasets', [])
|
||
|
||
if not labels or not datasets:
|
||
return None
|
||
|
||
# 饼图只使用第一个数据集
|
||
dataset = datasets[0]
|
||
dataset_data = dataset.get('data', [])
|
||
|
||
labels, dataset_data = self._align_labels_and_data(
|
||
labels,
|
||
dataset_data,
|
||
chart_type="饼",
|
||
require_positive_sum=True
|
||
)
|
||
|
||
if not labels or not dataset_data:
|
||
return None
|
||
|
||
title = props.get('title')
|
||
fig, ax = self._create_figure(width, height, dpi, title)
|
||
|
||
# 获取颜色
|
||
raw_colors = dataset.get('backgroundColor', self.DEFAULT_COLORS[:len(labels)])
|
||
if not isinstance(raw_colors, list):
|
||
raw_colors = self.DEFAULT_COLORS[:len(labels)]
|
||
|
||
colors = [
|
||
self._ensure_visible_color(
|
||
raw_colors[i] if i < len(raw_colors) else None,
|
||
self.DEFAULT_COLORS[i % len(self.DEFAULT_COLORS)]
|
||
)
|
||
for i in range(len(labels))
|
||
]
|
||
|
||
# 绘制饼图
|
||
wedges, texts, autotexts = ax.pie(
|
||
dataset_data,
|
||
labels=labels,
|
||
colors=colors,
|
||
autopct='%1.1f%%',
|
||
startangle=90,
|
||
textprops={'fontsize': 10}
|
||
)
|
||
|
||
# 设置百分比文字为白色
|
||
for autotext in autotexts:
|
||
autotext.set_color('white')
|
||
autotext.set_fontweight('bold')
|
||
|
||
ax.axis('equal') # 保持圆形
|
||
|
||
return self._figure_to_svg(fig)
|
||
|
||
except Exception as e:
|
||
logger.error(f"渲染饼图失败: {e}")
|
||
return None
|
||
|
||
def _render_doughnut(
|
||
self,
|
||
data: Dict[str, Any],
|
||
props: Dict[str, Any],
|
||
width: int,
|
||
height: int,
|
||
dpi: int
|
||
) -> Optional[str]:
|
||
"""渲染圆环图"""
|
||
try:
|
||
labels = data.get('labels', [])
|
||
datasets = data.get('datasets', [])
|
||
|
||
if not labels or not datasets:
|
||
return None
|
||
|
||
# 圆环图只使用第一个数据集
|
||
dataset = datasets[0]
|
||
dataset_data = dataset.get('data', [])
|
||
|
||
labels, dataset_data = self._align_labels_and_data(
|
||
labels,
|
||
dataset_data,
|
||
chart_type="圆环",
|
||
require_positive_sum=True
|
||
)
|
||
|
||
if not labels or not dataset_data:
|
||
return None
|
||
|
||
title = props.get('title')
|
||
fig, ax = self._create_figure(width, height, dpi, title)
|
||
|
||
# 获取颜色
|
||
raw_colors = dataset.get('backgroundColor', self.DEFAULT_COLORS[:len(labels)])
|
||
if not isinstance(raw_colors, list):
|
||
raw_colors = self.DEFAULT_COLORS[:len(labels)]
|
||
|
||
colors = [
|
||
self._ensure_visible_color(
|
||
raw_colors[i] if i < len(raw_colors) else None,
|
||
self.DEFAULT_COLORS[i % len(self.DEFAULT_COLORS)]
|
||
)
|
||
for i in range(len(labels))
|
||
]
|
||
|
||
# 绘制圆环图(通过设置wedgeprops实现中空效果)
|
||
wedges, texts, autotexts = ax.pie(
|
||
dataset_data,
|
||
labels=labels,
|
||
colors=colors,
|
||
autopct='%1.1f%%',
|
||
startangle=90,
|
||
wedgeprops=dict(width=0.5, edgecolor='white'),
|
||
textprops={'fontsize': 10}
|
||
)
|
||
|
||
# 设置百分比文字
|
||
for autotext in autotexts:
|
||
autotext.set_color('white')
|
||
autotext.set_fontweight('bold')
|
||
|
||
ax.axis('equal')
|
||
|
||
return self._figure_to_svg(fig)
|
||
|
||
except Exception as e:
|
||
logger.error(f"渲染圆环图失败: {e}")
|
||
return None
|
||
|
||
def _render_radar(
|
||
self,
|
||
data: Dict[str, Any],
|
||
props: Dict[str, Any],
|
||
width: int,
|
||
height: int,
|
||
dpi: int
|
||
) -> Optional[str]:
|
||
"""渲染雷达图"""
|
||
try:
|
||
labels = data.get('labels', [])
|
||
datasets = data.get('datasets', [])
|
||
|
||
if not labels or not datasets:
|
||
return None
|
||
|
||
title = props.get('title')
|
||
fig = plt.figure(figsize=(width/dpi, height/dpi), dpi=dpi)
|
||
|
||
# 创建极坐标子图
|
||
ax = fig.add_subplot(111, projection='polar')
|
||
|
||
if title:
|
||
ax.set_title(title, fontsize=14, fontweight='bold', pad=20)
|
||
|
||
colors = self._get_colors(datasets)
|
||
|
||
# 计算角度
|
||
angles = np.linspace(0, 2 * np.pi, len(labels), endpoint=False).tolist()
|
||
angles += angles[:1] # 闭合图形
|
||
|
||
# 绘制每个数据系列
|
||
for i, dataset in enumerate(datasets):
|
||
dataset_data = dataset.get('data', [])
|
||
label = dataset.get('label', f'系列{i+1}')
|
||
color = colors[i]
|
||
|
||
# 闭合数据
|
||
values = dataset_data + dataset_data[:1]
|
||
|
||
# 绘制雷达图
|
||
ax.plot(angles, values, 'o-', linewidth=2, label=label, color=color)
|
||
ax.fill(angles, values, alpha=0.25, color=color)
|
||
|
||
# 设置标签
|
||
ax.set_xticks(angles[:-1])
|
||
ax.set_xticklabels(labels)
|
||
|
||
# 显示图例
|
||
if len(datasets) > 1:
|
||
ax.legend(loc='upper right', bbox_to_anchor=(1.3, 1.1))
|
||
|
||
return self._figure_to_svg(fig)
|
||
|
||
except Exception as e:
|
||
logger.error(f"渲染雷达图失败: {e}")
|
||
return None
|
||
|
||
def _render_scatter(
|
||
self,
|
||
data: Dict[str, Any],
|
||
props: Dict[str, Any],
|
||
width: int,
|
||
height: int,
|
||
dpi: int
|
||
) -> Optional[str]:
|
||
"""渲染散点图"""
|
||
try:
|
||
datasets = data.get('datasets', [])
|
||
|
||
if not datasets:
|
||
return None
|
||
|
||
title = props.get('title')
|
||
fig, ax = self._create_figure(width, height, dpi, title)
|
||
|
||
colors = self._get_colors(datasets)
|
||
|
||
# 绘制每个数据系列
|
||
for i, dataset in enumerate(datasets):
|
||
dataset_data = dataset.get('data', [])
|
||
label = dataset.get('label', f'系列{i+1}')
|
||
color = colors[i]
|
||
|
||
# 提取x和y坐标
|
||
if dataset_data and isinstance(dataset_data[0], dict):
|
||
x_values = [point.get('x', 0) for point in dataset_data]
|
||
y_values = [point.get('y', 0) for point in dataset_data]
|
||
else:
|
||
# 如果不是{x,y}格式,使用索引作为x
|
||
x_values = range(len(dataset_data))
|
||
y_values = dataset_data
|
||
|
||
ax.scatter(
|
||
x_values,
|
||
y_values,
|
||
label=label,
|
||
color=color,
|
||
s=50,
|
||
alpha=0.6,
|
||
edgecolors='white',
|
||
linewidth=0.5
|
||
)
|
||
|
||
# 显示图例
|
||
if len(datasets) > 1:
|
||
ax.legend(loc='best', framealpha=0.9)
|
||
|
||
# 网格
|
||
ax.grid(True, alpha=0.3, linestyle='--')
|
||
|
||
return self._figure_to_svg(fig)
|
||
|
||
except Exception as e:
|
||
logger.error(f"渲染散点图失败: {e}")
|
||
return None
|
||
|
||
def _render_polarArea(
|
||
self,
|
||
data: Dict[str, Any],
|
||
props: Dict[str, Any],
|
||
width: int,
|
||
height: int,
|
||
dpi: int
|
||
) -> Optional[str]:
|
||
"""渲染极地区域图"""
|
||
try:
|
||
labels = data.get('labels', [])
|
||
datasets = data.get('datasets', [])
|
||
|
||
if not labels or not datasets:
|
||
return None
|
||
|
||
# 只使用第一个数据集
|
||
dataset = datasets[0]
|
||
dataset_data = dataset.get('data', [])
|
||
|
||
labels, dataset_data = self._align_labels_and_data(
|
||
labels,
|
||
dataset_data,
|
||
chart_type="极地区域",
|
||
require_positive_sum=False
|
||
)
|
||
|
||
if not labels or not dataset_data:
|
||
return None
|
||
|
||
title = props.get('title')
|
||
fig = plt.figure(figsize=(width/dpi, height/dpi), dpi=dpi)
|
||
ax = fig.add_subplot(111, projection='polar')
|
||
|
||
if title:
|
||
ax.set_title(title, fontsize=14, fontweight='bold', pad=20)
|
||
|
||
# 获取颜色
|
||
raw_colors = dataset.get('backgroundColor', self.DEFAULT_COLORS[:len(labels)])
|
||
if not isinstance(raw_colors, list):
|
||
raw_colors = self.DEFAULT_COLORS[:len(labels)]
|
||
|
||
colors = [
|
||
self._ensure_visible_color(
|
||
raw_colors[i] if i < len(raw_colors) else None,
|
||
self.DEFAULT_COLORS[i % len(self.DEFAULT_COLORS)]
|
||
)
|
||
for i in range(len(labels))
|
||
]
|
||
|
||
# 计算角度
|
||
theta = np.linspace(0, 2 * np.pi, len(labels), endpoint=False)
|
||
width_bar = 2 * np.pi / len(labels)
|
||
|
||
# 绘制极地区域图
|
||
bars = ax.bar(
|
||
theta,
|
||
dataset_data,
|
||
width=width_bar,
|
||
bottom=0.0,
|
||
color=colors,
|
||
alpha=0.7,
|
||
edgecolor='white',
|
||
linewidth=1
|
||
)
|
||
|
||
# 设置标签
|
||
ax.set_xticks(theta)
|
||
ax.set_xticklabels(labels)
|
||
|
||
return self._figure_to_svg(fig)
|
||
|
||
except Exception as e:
|
||
logger.error(f"渲染极地区域图失败: {e}")
|
||
return None
|
||
|
||
|
||
def create_chart_converter(font_path: Optional[str] = None) -> ChartToSVGConverter:
|
||
"""
|
||
创建图表转换器实例
|
||
|
||
参数:
|
||
font_path: 中文字体路径(可选)
|
||
|
||
返回:
|
||
ChartToSVGConverter: 转换器实例
|
||
"""
|
||
return ChartToSVGConverter(font_path=font_path)
|
||
|
||
|
||
__all__ = ["ChartToSVGConverter", "create_chart_converter"]
|