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

154 lines
6.9 KiB
Python

"""
RadarChart — переиспользуемый виджет 2D паутинной диаграммы (ft.Stack).
Отрицательные значения — красный полигон, положительные — фиолетовый.
Параметры:
items — список осей из API /responses/{id}/radar:
[{dimension_name, dimension_color, dimension_position, value}, ...]
max_val — максимум на шкале (default 14 — для теста Голланда)
"""
import math
import flet as ft
import flet.canvas as cv
import designer as ds
# ── размеры ─────────────────────────────────────────────────────────────────
_CS = 260 # размер canvas
_CX = _CS // 2
_CY = _CS // 2
_R = 78 # радиус внешнего кольца
_PAD = 95 # отступ вокруг canvas под подписи
_LBL_R = _R + 52 # расстояние от центра до подписи (в координатах Stack)
_W_LBL = 130 # ширина контейнера подписи
def _axis_xy(n: int, 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 _closed_path(pts: list[tuple]) -> list:
els = [cv.Path.MoveTo(*pts[0])]
for p in pts[1:]:
els.append(cv.Path.LineTo(*p))
els.append(cv.Path.Close())
return els
class RadarChart(ft.Stack):
"""2D паутинная диаграмма. Отрицательные=красный, позитивные=синий."""
def __init__(self, items: list[dict], max_val: float = 14.0):
n = len(items)
shapes: list = []
vals = [it["value"] for it in items]
max_abs = max((abs(v) for v in vals), default=1.0) if vals else 1.0
if max_val > 0:
max_abs = max(max_abs, max_val)
max_abs = max(max_abs, 1.0)
# ── Сетка ─────────────────────────────────────────────────────────────
for lvl in range(1, 6):
ring = [_axis_xy(n, i, lvl / 5) for i in range(n)]
alpha = int((0.10 + 0.09 * (lvl / 5)) * 255)
shapes.append(cv.Path(
_closed_path(ring),
paint=ft.Paint(
style=ft.PaintingStyle.STROKE,
stroke_width=1,
color=f"#{alpha:02X}F4EFFA",
),
))
# ── Оси от центра ──────────────────────────────────────────────────────
for i in range(n):
x, y = _axis_xy(n, i)
shapes.append(cv.Path(
[cv.Path.MoveTo(_CX, _CY), cv.Path.LineTo(x, y)],
paint=ft.Paint(stroke_width=1, color="#38F4EFFA"),
))
# ── Полигоны данных ────────────────────────────────────────────────────
neg_pts = [_axis_xy(n, i, max(0.0, min(-it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
pos_pts = [_axis_xy(n, i, max(0.0, min( it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
# Красный (отрицательные)
shapes.append(cv.Path(_closed_path(neg_pts),
paint=ft.Paint(style=ft.PaintingStyle.FILL, color="#50CC3030")))
shapes.append(cv.Path(_closed_path(neg_pts),
paint=ft.Paint(style=ft.PaintingStyle.STROKE, stroke_width=2.5, color="#FF7070")))
# Фиолетовый (положительные)
shapes.append(cv.Path(_closed_path(pos_pts),
paint=ft.Paint(style=ft.PaintingStyle.FILL, color="#505030A0")))
shapes.append(cv.Path(_closed_path(pos_pts),
paint=ft.Paint(style=ft.PaintingStyle.STROKE, stroke_width=2.5, color="#A080FF")))
# ── Точки на вершинах ──────────────────────────────────────────────────
for i, it in enumerate(items):
v = it["value"]
if abs(v) > 0.05:
px, py = _axis_xy(n, i, abs(v) / max_abs)
col = "#FF7070" if v < 0 else "#A080FF"
shapes.append(cv.Circle(px, py, 4, paint=ft.Paint(color=col)))
shapes.append(cv.Circle(px, py, 4,
paint=ft.Paint(style=ft.PaintingStyle.STROKE, stroke_width=1.5, color="#F4EFFA")))
canvas = cv.Canvas(shapes=shapes, width=_CS, height=_CS)
# ── Подписи осей в Stack-координатах ─────────────────────────────────
sw = _CS + 2 * _PAD
sh = _CS + 2 * _PAD
ccx = _PAD + _CX # центр графика в Stack
ccy = _PAD + _CY
labels: list[ft.Control] = []
for i, item in enumerate(items):
a = math.radians(-90.0 + 360.0 * i / n)
lx = ccx + _LBL_R * math.cos(a)
ly = ccy + _LBL_R * math.sin(a)
col = item.get("dimension_color") or ds.colors.primary
labels.append(ft.Container(
left=lx - _W_LBL / 2,
top=ly - 24,
width=_W_LBL,
content=ft.Column(
spacing=1,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
controls=[
ft.Text(
item["dimension_name"],
size=10,
color=col,
text_align=ft.TextAlign.CENTER,
weight=ft.FontWeight.BOLD,
overflow=ft.TextOverflow.FADE,
max_lines=2,
no_wrap=False,
),
ft.Text(
str(int(item["value"])),
size=11,
color=ds.colors.primary,
text_align=ft.TextAlign.CENTER,
),
],
),
))
super().__init__(
width=sw,
height=sh,
controls=[
ft.Container(
left=_PAD,
top=_PAD,
width=_CS,
height=_CS,
content=canvas,
),
*labels,
],
)