Files
api-copp/route/radar_svg_gen.py
2026-04-07 15:37:04 +05:00

210 lines
9.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">'
f'<text x="{size//2}" y="{size//2}" text-anchor="middle" fill="#333" '
f'font-family="Arial,sans-serif" font-size="16">Нет данных</text></svg>'
)
# ---- масштаб ----
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'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">',
]
# ── Сетка ─────────────────────────────────────────────────────────────────
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'<polygon points="{poly(pts)}" fill="none" '
f'stroke="rgba(170,130,230,{alpha:.2f})" stroke-width="1.2"/>'
)
# ── Оси ───────────────────────────────────────────────────────────────────
for i in range(n):
x, y = axis_xy(i)
lines.append(
f'<line x1="{cx:.2f}" y1="{cy:.2f}" x2="{x:.2f}" y2="{y:.2f}" '
f'stroke="rgba(170,130,230,0.45)" stroke-width="1.2"/>'
)
# ── Метки шкалы (первая ось) ──────────────────────────────────────────────
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'<text x="{tx + 4:.2f}" y="{ty:.2f}" '
f'font-family="Arial,sans-serif" font-size="10" '
f'fill="rgba(170,130,230,0.70)">{tick_val:.0f}</text>'
)
# ── Полигоны данных (красный=отрицательные, синий=положительные) ──────────
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'<polygon points="{poly(neg_pts)}" fill="rgba(220,50,50,0.30)" stroke="#FF7070" stroke-width="3.5"/>')
lines.append(f'<polygon points="{poly(pos_pts)}" fill="rgba(100,60,200,0.32)" stroke="#A080FF" stroke-width="3.5"/>')
# ── Точки ─────────────────────────────────────────────────────────────────
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'<circle cx="{x:.2f}" cy="{y:.2f}" r="6" fill="{col}" '
f'stroke="rgba(255,255,255,0.60)" stroke-width="1.5"/>'
)
# ── Подписи осей ───────────────────────────────────────────────────────────
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'<text x="{lx:.2f}" y="{y0 + k * line_h:.2f}" text-anchor="middle" '
f'font-family="Arial,Helvetica,sans-serif" font-size="{font_size}" '
f'font-weight="bold" fill="{col}">{_xml_escape(part)}</text>'
)
lines.append(
f'<text x="{lx:.2f}" y="{y0 + len(word_lines) * line_h:.2f}" '
f'text-anchor="middle" font-family="Arial,Helvetica,sans-serif" '
f'font-size="{font_size + 2}" fill="{val_color}">{val}</text>'
)
lines.append("</svg>")
return "\n".join(lines)
def _xml_escape(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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)