106 lines
4.9 KiB
Python
106 lines
4.9 KiB
Python
"""
|
||
radar_svg.py — генерация SVG паутинной диаграммы для сохранения на устройство.
|
||
|
||
Не требует сторонних зависимостей (чистый Python).
|
||
SVG содержит корректные шрифты и цвета, может быть открыт/сохранён в браузере.
|
||
"""
|
||
import math
|
||
import base64
|
||
|
||
|
||
def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520) -> str:
|
||
"""
|
||
Возвращает SVG-строку паутинной диаграммы.
|
||
|
||
items — список осей (dimension_name, dimension_color, value)
|
||
max_val — максимум шкалы
|
||
size — размер холста в пикселях
|
||
"""
|
||
n = len(items)
|
||
if n < 3:
|
||
return f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}"><text x="10" y="20" fill="white">Нет данных</text></svg>'
|
||
|
||
cx = cy = size / 2
|
||
R = size * 0.27 # радиус внешнего кольца
|
||
lbl_r = R + size * 0.12 # расстояние до подписей от центра
|
||
safe_max = max_val if max_val > 0 else 1.0
|
||
|
||
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'<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)]
|
||
lines.append(
|
||
f'<polygon points="{poly_pts(pts)}" '
|
||
f'fill="none" stroke="rgba(244,239,250,0.18)" stroke-width="1"/>'
|
||
)
|
||
|
||
# ── оси ─────────────────────────────────────────────────────────────────
|
||
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(244,239,250,0.22)" stroke-width="1"/>'
|
||
)
|
||
|
||
# ── полигон данных ───────────────────────────────────────────────────────
|
||
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"/>'
|
||
)
|
||
|
||
# ── точки на вершинах ────────────────────────────────────────────────────
|
||
for x, y in data_pts:
|
||
lines.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="5" fill="#F4EFFA"/>')
|
||
|
||
# ── подписи осей ─────────────────────────────────────────────────────────
|
||
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"])
|
||
|
||
# название типа (жирный, цветной)
|
||
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>'
|
||
)
|
||
|
||
lines.append("</svg>")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _xml_escape(s: str) -> str:
|
||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||
|
||
|
||
def get_radar_data_uri(items: list[dict], max_val: float = 14.0) -> str:
|
||
"""
|
||
Возвращает data URI вида «data:image/svg+xml;base64,…».
|
||
Передайте в page.launch_url(...) чтобы открыть/сохранить изображение.
|
||
"""
|
||
svg = generate_radar_svg(items, max_val)
|
||
b64 = base64.b64encode(svg.encode("utf-8")).decode()
|
||
return f"data:image/svg+xml;base64,{b64}"
|