fix install diogram

This commit is contained in:
2026-04-02 12:00:11 +05:00
parent 22bbc3a0a6
commit 8a189e310b
23 changed files with 1212 additions and 189 deletions

View File

@@ -8,27 +8,46 @@ import os
import base64
def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520) -> str:
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 — максимум шкалы (14 для теста Голланда)
size — размер холста в пикселях
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'<rect width="{size}" height="{size}" fill="#2F184B" rx="16"/>'
f'<text x="{size//2}" y="{size//2}" text-anchor="middle" fill="white" '
f'<text x="{size//2}" y="{size//2}" text-anchor="middle" fill="#333" '
f'font-family="Arial,sans-serif" font-size="16">Нет данных</text></svg>'
)
# ---- масштаб ----
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
R = size * 0.27
lbl_r = R + size * 0.13
safe_max = max_val if max_val > 0 else 1.0
# Радиус сетки: оставляем место для подписей со всех сторон
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)
@@ -39,15 +58,16 @@ def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520
lines: list[str] = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">',
f'<rect width="{size}" height="{size}" fill="#2F184B" rx="16"/>',
]
# Концентрические сетки
for lvl in range(1, 6):
pts = [axis_xy(i, lvl / 5) for i in range(n)]
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'<polygon points="{poly_pts(pts)}" '
f'fill="none" stroke="rgba(244,239,250,0.18)" stroke-width="1"/>'
f'fill="none" stroke="rgba(170,130,230,{alpha:.2f})" stroke-width="1.2"/>'
)
# Оси от центра
@@ -55,38 +75,57 @@ def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520
x, y = axis_xy(i)
lines.append(
f'<line x1="{cx:.2f}" y1="{cy:.2f}" x2="{x:.2f}" y2="{y:.2f}" '
f'stroke="rgba(244,239,250,0.22)" stroke-width="1"/>'
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 = safe_max * 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="#A070D0">{tick_val:.0f}</text>'
)
# Полигон данных
data_pts = [axis_xy(i, min(it["value"] / safe_max, 1.0)) for i, it in enumerate(items)]
lines.append(
f'<polygon points="{poly_pts(data_pts)}" '
f'fill="rgba(155,114,207,0.55)" stroke="#F4EFFA" stroke-width="2.5"/>'
f'fill="rgba(180,140,240,0.35)" stroke="#A070D0" stroke-width="3.5"/>'
)
# Точки на вершинах
for x, y in data_pts:
lines.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="5" fill="#F4EFFA"/>')
lines.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="6" fill="#A070D0"/>')
# Подписи
# Подписи осей (с переносом длинных строк по ' — ' или ' ')
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 "#F4EFFA"
name = _xml_escape(item["dimension_name"])
val = int(item["value"])
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'<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="{ly:.2f}" text-anchor="middle" '
f'font-family="Arial,Helvetica,sans-serif" font-size="14" '
f'font-weight="bold" fill="{col}">{name}</text>'
)
lines.append(
f'<text x="{lx:.2f}" y="{ly + 20:.2f}" text-anchor="middle" '
f'font-family="Arial,Helvetica,sans-serif" font-size="13" '
f'fill="#F4EFFA">{val}</text>'
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="#A070D0">{val}</text>'
)
lines.append("</svg>")
@@ -97,22 +136,66 @@ def _xml_escape(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def save_radar_svg(result_id: str, items: list[dict], max_val: float = 14.0) -> str:
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/{result_id}.svg
Возвращает: data/radar/{result_id}.svg
Файл пишется в: {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")
radar_dir = os.path.join(app_path, "data", "radar")
os.makedirs(radar_dir, exist_ok=True)
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(radar_dir, filename)
svg = generate_radar_svg(items, max_val)
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 f"data/radar/{filename}"
return os.path.join(rel_dir, filename)