""" radar_svg_gen.py — серверная генерация SVG паутинной диаграммы. Используется при сохранении результата: файл пишется в APP_PATH/data/radar/. Не требует сторонних зависимостей. """ import math import os import base64 def generate_radar_svg( items: list[dict], max_val: float = 0.0, size: int = 520, scale_padding: float = 0.2, min_scale: float = 5.0, grid_levels: int = 5, show_scale_labels: bool = True, ) -> str: """ Возвращает строку SVG паутинной диаграммы. items — список осей: [{dimension_name, dimension_color, value}, ...] max_val — максимум шкалы; 0 = авто (вычисляется из items) size — размер холста в пикселях scale_padding — запас над максимальным значением (доля, 0.2 = +20%) min_scale — минимальное значение шкалы (чтобы маленькие значения не выглядели огромными) grid_levels — количество концентрических колец show_scale_labels — рисовать ли подписи значений на одной из осей """ n = len(items) if n < 3: return ( f'' f'Нет данных' ) # ---- масштаб ---- data_max = max((it["value"] for it in items), default=1.0) if max_val <= 0: max_val = data_max safe_max = max(max_val * (1.0 + scale_padding), min_scale, 1.0) # ---- геометрия ---- cx = cy = size / 2 # Радиус сетки: оставляем место для подписей со всех сторон lbl_margin = size * 0.25 # запас под подписи R = (size / 2) - lbl_margin # сетка заполняет максимум пространства lbl_r = R + size * 0.06 # подписи чуть дальше от сетки def axis_xy(i: int, frac: float = 1.0) -> tuple[float, float]: a = math.radians(-90.0 + 360.0 * i / n) return cx + R * frac * math.cos(a), cy + R * frac * math.sin(a) def poly_pts(pts: list[tuple]) -> str: return " ".join(f"{x:.2f},{y:.2f}" for x, y in pts) lines: list[str] = [ f'', ] # Концентрические сетки for lvl in range(1, grid_levels + 1): frac = lvl / grid_levels pts = [axis_xy(i, frac) for i in range(n)] alpha = 0.28 + 0.14 * (lvl / grid_levels) # внешние кольца чуть ярче lines.append( f'' ) # Оси от центра for i in range(n): x, y = axis_xy(i) lines.append( f'' ) # Подписи значений на первой оси (вертикально вверх) if show_scale_labels: for lvl in range(1, grid_levels + 1): frac = lvl / grid_levels tick_val = safe_max * frac tx, ty = axis_xy(0, frac) lines.append( f'{tick_val:.0f}' ) # Полигон данных data_pts = [axis_xy(i, min(it["value"] / safe_max, 1.0)) for i, it in enumerate(items)] lines.append( f'' ) # Точки на вершинах for x, y in data_pts: lines.append(f'') # Подписи осей (с переносом длинных строк по ' — ' или ' ') font_size = max(11, min(14, int(size / 40))) for i, item in enumerate(items): a = math.radians(-90.0 + 360.0 * i / n) lx = cx + lbl_r * math.cos(a) ly = cy + lbl_r * math.sin(a) col = item.get("dimension_color") or "#2F184B" val = int(item["value"]) word_lines = _wrap_label(item["dimension_name"]) # Вертикальное центрирование: сдвигаем вверх на половину суммарной высоты line_h = font_size + 3 total_h = line_h * len(word_lines) + (font_size + 2) # +строка со значением y0 = ly - total_h / 2 + line_h for k, part in enumerate(word_lines): lines.append( f'{_xml_escape(part)}' ) lines.append( f'{val}' ) lines.append("") return "\n".join(lines) def _xml_escape(s: str) -> str: return s.replace("&", "&").replace("<", "<").replace(">", ">") def _wrap_label(text: str, max_chars: int = 14) -> list[str]: """ Разбивает длинные подписи осей на строки. Сначала пробует разрезать по ' — ', затем по пробелам. """ if len(text) <= max_chars: return [text] # разрезаем по длинному тире if " — " in text: parts = text.split(" — ", 1) return [parts[0] + " —", parts[1]] # разрезаем по последнему пробелу ≤ max_chars idx = text.rfind(" ", 0, max_chars) if idx == -1: return [text] return [text[:idx], text[idx + 1:]] def save_radar_svg( result_id: str, items: list[dict], max_val: float = 0.0, poll_title: str = "unknown", user_id: str = "anonymous", size: int = 520, scale_padding: float = 0.2, min_scale: float = 5.0, ) -> str: """ Генерирует SVG и сохраняет его на диск. Возвращает относительный путь к файлу (относительно APP_PATH). Файл пишется в: {APP_PATH}/data/radar/{poll_title}/{user_id}/{result_id}.svg Возвращает: data/radar/{poll_title}/{user_id}/{result_id}.svg max_val=0 — автомасштаб (рекомендуется) scale_padding — запас над максимумом (0.2 = +20%) min_scale — нижняя граница шкалы """ import re def _safe(s: str) -> str: s = re.sub(r"[^\w\-. ]", "_", s, flags=re.UNICODE) return s.strip("_. ").replace(" ", "_") or "_" app_path = os.getenv("APP_PATH", "/app") rel_dir = os.path.join("data", "radar", _safe(poll_title), str(user_id)) full_dir = os.path.join(app_path, rel_dir) os.makedirs(full_dir, exist_ok=True) filename = f"{result_id}.svg" full_path = os.path.join(full_dir, filename) svg = generate_radar_svg( items, max_val=max_val, size=size, scale_padding=scale_padding, min_scale=min_scale, ) with open(full_path, "w", encoding="utf-8") as f: f.write(svg) return os.path.join(rel_dir, filename)