""" 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, ], )