fix install diogram
This commit is contained in:
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user