""" radar_svg_gen.py — серверная генерация SVG паутинной диаграммы. Используется при сохранении результата: файл пишется в APP_PATH/data/radar/. Не требует сторонних зависимостей. """ import math import os 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'Нет данных' ) # ---- масштаб ---- vals = [it["value"] for it in items] max_abs = max((abs(v) for v in vals), default=1.0) if max_val > 0: max_abs = max(max_abs, max_val) max_abs = max(max_abs * (1.0 + scale_padding), min_scale, 1.0) # ---- геометрия ---- cx = size / 2 cy = size / 2 lbl_margin = size * 0.22 R = (size / 2) - lbl_margin lbl_r = R + size * 0.07 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: 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.15 + 0.12 * frac 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 = max_abs * frac tx, ty = axis_xy(0, frac) lines.append( f'{tick_val:.0f}' ) # ── Полигоны данных (красный=отрицательные, синий=положительные) ────────── neg_pts = [axis_xy(i, max(0.0, min(-it["value"] / max_abs, 1.0))) for i, it in enumerate(items)] pos_pts = [axis_xy(i, max(0.0, min( it["value"] / max_abs, 1.0))) for i, it in enumerate(items)] lines.append(f'') lines.append(f'') # ── Точки ───────────────────────────────────────────────────────────────── for i, it in enumerate(items): v = it["value"] if abs(v) > 0.05: x, y = axis_xy(i, abs(v) / max_abs) col = "#FF7070" if v < 0 else "#A080FF" 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 "#A070D0" val = int(item["value"]) val_color = "#FF7070" if item["value"] < 0 else "#A080FF" 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)