update diogram
This commit is contained in:
120
nginx.production.conf
Normal file
120
nginx.production.conf
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# ============================================================
|
||||||
|
# nginx — production config для test.cloud-copp74.ru
|
||||||
|
# Устанавливается на отдельном nginx-сервере с certbot.
|
||||||
|
#
|
||||||
|
# Замените APP_IP на внутренний IP сервера с Docker.
|
||||||
|
#
|
||||||
|
# Первый запуск (получение сертификата):
|
||||||
|
# 1. Разместите этот файл в /etc/nginx/sites-available/copp
|
||||||
|
# 2. Создайте симлинк: ln -s /etc/nginx/sites-available/copp /etc/nginx/sites-enabled/
|
||||||
|
# 3. Временно раскомментируйте блок certbot-challenge (см. ниже)
|
||||||
|
# 4. nginx -t && systemctl reload nginx
|
||||||
|
# 5. certbot --nginx -d test.cloud-copp74.ru
|
||||||
|
# 6. certbot создаст ssl-блок автоматически или используйте конфиг ниже
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Сервер приложения
|
||||||
|
upstream flet_app {
|
||||||
|
server 192.168.1.18:80;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── HTTP → HTTPS редирект ────────────────────────────────────
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name test.cloud-copp74.ru;
|
||||||
|
|
||||||
|
# certbot challenge (оставьте всегда, certbot сам обновляет сертификат)
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── HTTPS ────────────────────────────────────────────────────
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name test.cloud-copp74.ru;
|
||||||
|
|
||||||
|
# Сертификаты Let's Encrypt (certbot заполнит сам, или укажите вручную)
|
||||||
|
ssl_certificate /etc/letsencrypt/live/test.cloud-copp74.ru/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/test.cloud-copp74.ru/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
# ── Безопасность ────────────────────────────────────────
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Frame-Options SAMEORIGIN always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# ── Производительность ──────────────────────────────────
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_comp_level 5;
|
||||||
|
gzip_vary on;
|
||||||
|
|
||||||
|
client_max_body_size 16m;
|
||||||
|
|
||||||
|
# ── WebSocket (Flet) ────────────────────────────────────
|
||||||
|
# Flet держит постоянное WS-соединение на /ws
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://flet_app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Долгоживущие WS-соединения — не рвать
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Статика Flet (assets, иконки) ───────────────────────
|
||||||
|
location ~* \.(js|css|woff2?|svg|png|jpg|ico|webp)$ {
|
||||||
|
proxy_pass http://flet_app;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Кешируем статику на клиенте 7 дней
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── SVG диаграмма (radar-image) ─────────────────────────
|
||||||
|
location /radar-image/ {
|
||||||
|
proxy_pass http://flet_app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Всё остальное → Flet app ────────────────────────────
|
||||||
|
location / {
|
||||||
|
proxy_pass http://flet_app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ radar_svg_gen.py — серверная генерация SVG паутинно
|
|||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import base64
|
|
||||||
|
|
||||||
|
|
||||||
def generate_radar_svg(
|
def generate_radar_svg(
|
||||||
@@ -18,15 +17,16 @@ def generate_radar_svg(
|
|||||||
show_scale_labels: bool = True,
|
show_scale_labels: bool = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Возвращает строку SVG паутинной диаграммы.
|
Возвращает SVG паутинной диаграммы.
|
||||||
|
|
||||||
items — список осей: [{dimension_name, dimension_color, value}, ...]
|
items — список осей: [{dimension_name, dimension_color, value}, ...]
|
||||||
max_val — максимум шкалы; 0 = авто (вычисляется из items)
|
max_val — максимум шкалы; 0 = авто (вычисляется из items)
|
||||||
size — размер холста в пикселях
|
size — размер холста в пикселях
|
||||||
scale_padding — запас над максимальным значением (доля, 0.2 = +20%)
|
scale_padding — запас над максимальным значением (доля, 0.2 = +20%)
|
||||||
min_scale — минимальное значение шкалы (чтобы маленькие значения не выглядели огромными)
|
min_scale — минимальное значение шкалы
|
||||||
grid_levels — количество концентрических колец
|
grid_levels — количество концентрических колец
|
||||||
show_scale_labels — рисовать ли подписи значений на одной из осей
|
show_scale_labels — рисовать ли подписи значений на одной из осей
|
||||||
|
Отрицательные значения окрашиваются красным, положительные — фиолетовым.
|
||||||
"""
|
"""
|
||||||
n = len(items)
|
n = len(items)
|
||||||
if n < 3:
|
if n < 3:
|
||||||
@@ -37,40 +37,41 @@ def generate_radar_svg(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ---- масштаб ----
|
# ---- масштаб ----
|
||||||
data_max = max((it["value"] for it in items), default=1.0)
|
vals = [it["value"] for it in items]
|
||||||
if max_val <= 0:
|
max_abs = max((abs(v) for v in vals), default=1.0)
|
||||||
max_val = data_max
|
if max_val > 0:
|
||||||
safe_max = max(max_val * (1.0 + scale_padding), min_scale, 1.0)
|
max_abs = max(max_abs, max_val)
|
||||||
|
max_abs = max(max_abs * (1.0 + scale_padding), min_scale, 1.0)
|
||||||
|
|
||||||
# ---- геометрия ----
|
# ---- геометрия ----
|
||||||
cx = cy = size / 2
|
cx = size / 2
|
||||||
# Радиус сетки: оставляем место для подписей со всех сторон
|
cy = size / 2
|
||||||
lbl_margin = size * 0.25 # запас под подписи
|
lbl_margin = size * 0.22
|
||||||
R = (size / 2) - lbl_margin # сетка заполняет максимум пространства
|
R = (size / 2) - lbl_margin
|
||||||
lbl_r = R + size * 0.06 # подписи чуть дальше от сетки
|
lbl_r = R + size * 0.07
|
||||||
|
|
||||||
def axis_xy(i: int, frac: float = 1.0) -> tuple[float, float]:
|
def axis_xy(i: int, frac: float = 1.0) -> tuple[float, float]:
|
||||||
a = math.radians(-90.0 + 360.0 * i / n)
|
a = math.radians(-90.0 + 360.0 * i / n)
|
||||||
return cx + R * frac * math.cos(a), cy + R * frac * math.sin(a)
|
return cx + R * frac * math.cos(a), cy + R * frac * math.sin(a)
|
||||||
|
|
||||||
def poly_pts(pts: list[tuple]) -> str:
|
def poly(pts: list[tuple]) -> str:
|
||||||
return " ".join(f"{x:.2f},{y:.2f}" for x, y in pts)
|
return " ".join(f"{x:.2f},{y:.2f}" for x, y in pts)
|
||||||
|
|
||||||
lines: list[str] = [
|
lines: list[str] = [
|
||||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">',
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Концентрические сетки
|
# ── Сетка ─────────────────────────────────────────────────────────────────
|
||||||
for lvl in range(1, grid_levels + 1):
|
for lvl in range(1, grid_levels + 1):
|
||||||
frac = lvl / grid_levels
|
frac = lvl / grid_levels
|
||||||
pts = [axis_xy(i, frac) for i in range(n)]
|
pts = [axis_xy(i, frac) for i in range(n)]
|
||||||
alpha = 0.28 + 0.14 * (lvl / grid_levels) # внешние кольца чуть ярче
|
alpha = 0.15 + 0.12 * frac
|
||||||
lines.append(
|
lines.append(
|
||||||
f'<polygon points="{poly_pts(pts)}" '
|
f'<polygon points="{poly(pts)}" fill="none" '
|
||||||
f'fill="none" stroke="rgba(170,130,230,{alpha:.2f})" stroke-width="1.2"/>'
|
f'stroke="rgba(170,130,230,{alpha:.2f})" stroke-width="1.2"/>'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Оси от центра
|
# ── Оси ───────────────────────────────────────────────────────────────────
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
x, y = axis_xy(i)
|
x, y = axis_xy(i)
|
||||||
lines.append(
|
lines.append(
|
||||||
@@ -78,43 +79,50 @@ def generate_radar_svg(
|
|||||||
f'stroke="rgba(170,130,230,0.45)" stroke-width="1.2"/>'
|
f'stroke="rgba(170,130,230,0.45)" stroke-width="1.2"/>'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Подписи значений на первой оси (вертикально вверх)
|
# ── Метки шкалы (первая ось) ──────────────────────────────────────────────
|
||||||
if show_scale_labels:
|
if show_scale_labels:
|
||||||
for lvl in range(1, grid_levels + 1):
|
for lvl in range(1, grid_levels + 1):
|
||||||
frac = lvl / grid_levels
|
frac = lvl / grid_levels
|
||||||
tick_val = safe_max * frac
|
tick_val = max_abs * frac
|
||||||
tx, ty = axis_xy(0, frac)
|
tx, ty = axis_xy(0, frac)
|
||||||
lines.append(
|
lines.append(
|
||||||
f'<text x="{tx + 4:.2f}" y="{ty:.2f}" '
|
f'<text x="{tx + 4:.2f}" y="{ty:.2f}" '
|
||||||
f'font-family="Arial,sans-serif" font-size="10" '
|
f'font-family="Arial,sans-serif" font-size="10" '
|
||||||
f'fill="#A070D0">{tick_val:.0f}</text>'
|
f'fill="rgba(170,130,230,0.70)">{tick_val:.0f}</text>'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Полигон данных
|
# ── Полигоны данных (красный=отрицательные, синий=положительные) ──────────
|
||||||
data_pts = [axis_xy(i, min(it["value"] / safe_max, 1.0)) for i, it in enumerate(items)]
|
neg_pts = [axis_xy(i, max(0.0, min(-it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
|
||||||
lines.append(
|
pos_pts = [axis_xy(i, max(0.0, min( it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
|
||||||
f'<polygon points="{poly_pts(data_pts)}" '
|
|
||||||
f'fill="rgba(180,140,240,0.35)" stroke="#A070D0" stroke-width="3.5"/>'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Точки на вершинах
|
lines.append(f'<polygon points="{poly(neg_pts)}" fill="rgba(220,50,50,0.30)" stroke="#FF7070" stroke-width="3.5"/>')
|
||||||
for x, y in data_pts:
|
lines.append(f'<polygon points="{poly(pos_pts)}" fill="rgba(100,60,200,0.32)" stroke="#A080FF" stroke-width="3.5"/>')
|
||||||
lines.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="6" fill="#A070D0"/>')
|
|
||||||
|
|
||||||
# Подписи осей (с переносом длинных строк по ' — ' или ' ')
|
# ── Точки ─────────────────────────────────────────────────────────────────
|
||||||
|
for i, it in enumerate(items):
|
||||||
|
v = it["value"]
|
||||||
|
if abs(v) > 0.05:
|
||||||
|
x, y = axis_xy(i, abs(v) / max_abs)
|
||||||
|
col = "#FF7070" if v < 0 else "#A080FF"
|
||||||
|
lines.append(
|
||||||
|
f'<circle cx="{x:.2f}" cy="{y:.2f}" r="6" fill="{col}" '
|
||||||
|
f'stroke="rgba(255,255,255,0.60)" stroke-width="1.5"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Подписи осей ───────────────────────────────────────────────────────────
|
||||||
font_size = max(11, min(14, int(size / 40)))
|
font_size = max(11, min(14, int(size / 40)))
|
||||||
for i, item in enumerate(items):
|
for i, item in enumerate(items):
|
||||||
a = math.radians(-90.0 + 360.0 * i / n)
|
a = math.radians(-90.0 + 360.0 * i / n)
|
||||||
lx = cx + lbl_r * math.cos(a)
|
lx = cx + lbl_r * math.cos(a)
|
||||||
ly = cy + lbl_r * math.sin(a)
|
ly = cy + lbl_r * math.sin(a)
|
||||||
col = item.get("dimension_color") or "#2F184B"
|
col = item.get("dimension_color") or "#A070D0"
|
||||||
val = int(item["value"])
|
val = int(item["value"])
|
||||||
|
val_color = "#FF7070" if item["value"] < 0 else "#A080FF"
|
||||||
|
|
||||||
word_lines = _wrap_label(item["dimension_name"])
|
word_lines = _wrap_label(item["dimension_name"])
|
||||||
# Вертикальное центрирование: сдвигаем вверх на половину суммарной высоты
|
line_h = font_size + 3
|
||||||
line_h = font_size + 3
|
total_h = line_h * len(word_lines) + (font_size + 2)
|
||||||
total_h = line_h * len(word_lines) + (font_size + 2) # +строка со значением
|
y0 = ly - total_h / 2 + line_h
|
||||||
y0 = ly - total_h / 2 + line_h
|
|
||||||
|
|
||||||
for k, part in enumerate(word_lines):
|
for k, part in enumerate(word_lines):
|
||||||
lines.append(
|
lines.append(
|
||||||
@@ -125,7 +133,7 @@ def generate_radar_svg(
|
|||||||
lines.append(
|
lines.append(
|
||||||
f'<text x="{lx:.2f}" y="{y0 + len(word_lines) * line_h:.2f}" '
|
f'<text x="{lx:.2f}" y="{y0 + len(word_lines) * line_h:.2f}" '
|
||||||
f'text-anchor="middle" font-family="Arial,Helvetica,sans-serif" '
|
f'text-anchor="middle" font-family="Arial,Helvetica,sans-serif" '
|
||||||
f'font-size="{font_size + 2}" fill="#A070D0">{val}</text>'
|
f'font-size="{font_size + 2}" fill="{val_color}">{val}</text>'
|
||||||
)
|
)
|
||||||
|
|
||||||
lines.append("</svg>")
|
lines.append("</svg>")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
RadarChart — переиспользуемый виджет паутинной диаграммы (ft.Stack).
|
RadarChart — переиспользуемый виджет 2D паутинной диаграммы (ft.Stack).
|
||||||
|
Отрицательные значения — красный полигон, положительные — фиолетовый.
|
||||||
|
|
||||||
Параметры:
|
Параметры:
|
||||||
items — список осей из API /responses/{id}/radar:
|
items — список осей из API /responses/{id}/radar:
|
||||||
@@ -12,13 +13,13 @@ import flet.canvas as cv
|
|||||||
import designer as ds
|
import designer as ds
|
||||||
|
|
||||||
# ── размеры ─────────────────────────────────────────────────────────────────
|
# ── размеры ─────────────────────────────────────────────────────────────────
|
||||||
_CS = 260 # размер canvas (×2 = итоговая зона графика)
|
_CS = 260 # размер canvas
|
||||||
_CX = _CS // 2
|
_CX = _CS // 2
|
||||||
_CY = _CS // 2
|
_CY = _CS // 2
|
||||||
_R = 80 # радиус внешнего кольца
|
_R = 78 # радиус внешнего кольца
|
||||||
_PAD = 95 # отступ вокруг canvas — оставляем место для подписей
|
_PAD = 95 # отступ вокруг canvas под подписи
|
||||||
_LBL_R = _R + 50 # расстояние от центра до центра подписи (в координатах Stack)
|
_LBL_R = _R + 52 # расстояние от центра до подписи (в координатах Stack)
|
||||||
_W_LBL = 130 # ширина контейнера подписи (достаточно для «Конвенциональный»)
|
_W_LBL = 130 # ширина контейнера подписи
|
||||||
|
|
||||||
|
|
||||||
def _axis_xy(n: int, i: int, frac: float = 1.0) -> tuple[float, float]:
|
def _axis_xy(n: int, i: int, frac: float = 1.0) -> tuple[float, float]:
|
||||||
@@ -35,56 +36,67 @@ def _closed_path(pts: list[tuple]) -> list:
|
|||||||
|
|
||||||
|
|
||||||
class RadarChart(ft.Stack):
|
class RadarChart(ft.Stack):
|
||||||
"""Паутинная диаграмма типов/сфер по данным radar API."""
|
"""2D паутинная диаграмма. Отрицательные=красный, позитивные=синий."""
|
||||||
|
|
||||||
def __init__(self, items: list[dict], max_val: float = 14.0):
|
def __init__(self, items: list[dict], max_val: float = 14.0):
|
||||||
n = len(items)
|
n = len(items)
|
||||||
shapes: list = []
|
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)
|
||||||
|
|
||||||
# ── сетка (5 уровней) ────────────────────────────────────────────────
|
# ── Сетка ─────────────────────────────────────────────────────────────
|
||||||
for lvl in range(1, 6):
|
for lvl in range(1, 6):
|
||||||
ring = [_axis_xy(n, i, lvl / 5) for i in range(n)]
|
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(
|
shapes.append(cv.Path(
|
||||||
_closed_path(ring),
|
_closed_path(ring),
|
||||||
paint=ft.Paint(
|
paint=ft.Paint(
|
||||||
style=ft.PaintingStyle.STROKE,
|
style=ft.PaintingStyle.STROKE,
|
||||||
stroke_width=1,
|
stroke_width=1,
|
||||||
color="#30F4EFFA",
|
color=f"#{alpha:02X}F4EFFA",
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
|
|
||||||
# ── оси от центра ────────────────────────────────────────────────────
|
# ── Оси от центра ──────────────────────────────────────────────────────
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
x, y = _axis_xy(n, i)
|
x, y = _axis_xy(n, i)
|
||||||
shapes.append(cv.Path(
|
shapes.append(cv.Path(
|
||||||
[cv.Path.MoveTo(_CX, _CY), cv.Path.LineTo(x, y)],
|
[cv.Path.MoveTo(_CX, _CY), cv.Path.LineTo(x, y)],
|
||||||
paint=ft.Paint(stroke_width=1, color="#40F4EFFA"),
|
paint=ft.Paint(stroke_width=1, color="#38F4EFFA"),
|
||||||
))
|
))
|
||||||
|
|
||||||
# ── полигон данных ───────────────────────────────────────────────────
|
# ── Полигоны данных ────────────────────────────────────────────────────
|
||||||
safe_max = max_val if max_val > 0 else 1.0
|
neg_pts = [_axis_xy(n, i, max(0.0, min(-it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
|
||||||
data_pts = [
|
pos_pts = [_axis_xy(n, i, max(0.0, min( it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
|
||||||
_axis_xy(n, i, min(it["value"] / safe_max, 1.0))
|
|
||||||
for i, it in enumerate(items)
|
# Красный (отрицательные)
|
||||||
]
|
shapes.append(cv.Path(_closed_path(neg_pts),
|
||||||
shapes.append(cv.Path(
|
paint=ft.Paint(style=ft.PaintingStyle.FILL, color="#50CC3030")))
|
||||||
_closed_path(data_pts),
|
shapes.append(cv.Path(_closed_path(neg_pts),
|
||||||
paint=ft.Paint(style=ft.PaintingStyle.FILL, color="#609B72CF"),
|
paint=ft.Paint(style=ft.PaintingStyle.STROKE, stroke_width=2.5, color="#FF7070")))
|
||||||
))
|
|
||||||
shapes.append(cv.Path(
|
# Фиолетовый (положительные)
|
||||||
_closed_path(data_pts),
|
shapes.append(cv.Path(_closed_path(pos_pts),
|
||||||
paint=ft.Paint(
|
paint=ft.Paint(style=ft.PaintingStyle.FILL, color="#505030A0")))
|
||||||
style=ft.PaintingStyle.STROKE,
|
shapes.append(cv.Path(_closed_path(pos_pts),
|
||||||
stroke_width=2.5,
|
paint=ft.Paint(style=ft.PaintingStyle.STROKE, stroke_width=2.5, color="#A080FF")))
|
||||||
color="#F4EFFA",
|
|
||||||
),
|
# ── Точки на вершинах ──────────────────────────────────────────────────
|
||||||
))
|
for i, it in enumerate(items):
|
||||||
for px, py in data_pts:
|
v = it["value"]
|
||||||
shapes.append(cv.Circle(px, py, 4, paint=ft.Paint(color="#F4EFFA")))
|
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)
|
canvas = cv.Canvas(shapes=shapes, width=_CS, height=_CS)
|
||||||
|
|
||||||
# ── подписи оси в Stack-координатах ──────────────────────────────────
|
# ── Подписи осей в Stack-координатах ─────────────────────────────────
|
||||||
sw = _CS + 2 * _PAD
|
sw = _CS + 2 * _PAD
|
||||||
sh = _CS + 2 * _PAD
|
sh = _CS + 2 * _PAD
|
||||||
ccx = _PAD + _CX # центр графика в Stack
|
ccx = _PAD + _CX # центр графика в Stack
|
||||||
@@ -92,9 +104,9 @@ class RadarChart(ft.Stack):
|
|||||||
|
|
||||||
labels: list[ft.Control] = []
|
labels: list[ft.Control] = []
|
||||||
for i, item in enumerate(items):
|
for i, item in enumerate(items):
|
||||||
a = math.radians(-90.0 + 360.0 * i / n)
|
a = math.radians(-90.0 + 360.0 * i / n)
|
||||||
lx = ccx + _LBL_R * math.cos(a)
|
lx = ccx + _LBL_R * math.cos(a)
|
||||||
ly = ccy + _LBL_R * math.sin(a)
|
ly = ccy + _LBL_R * math.sin(a)
|
||||||
col = item.get("dimension_color") or ds.colors.primary
|
col = item.get("dimension_color") or ds.colors.primary
|
||||||
|
|
||||||
labels.append(ft.Container(
|
labels.append(ft.Container(
|
||||||
@@ -111,7 +123,6 @@ class RadarChart(ft.Stack):
|
|||||||
color=col,
|
color=col,
|
||||||
text_align=ft.TextAlign.CENTER,
|
text_align=ft.TextAlign.CENTER,
|
||||||
weight=ft.FontWeight.BOLD,
|
weight=ft.FontWeight.BOLD,
|
||||||
# разрешаем перенос только по словам (не по символам)
|
|
||||||
overflow=ft.TextOverflow.FADE,
|
overflow=ft.TextOverflow.FADE,
|
||||||
max_lines=2,
|
max_lines=2,
|
||||||
no_wrap=False,
|
no_wrap=False,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
radar_svg.py — генерация SVG паутинной диаграммы для сохранения на устройство.
|
radar_svg.py — генерация SVG паутинной диаграммы для сохранения.
|
||||||
|
|
||||||
Не требует сторонних зависимостей (чистый Python).
|
Не требует сторонних зависимостей (чистый Python).
|
||||||
SVG содержит корректные шрифты и цвета, может быть открыт/сохранён в браузере.
|
Отрицательные значения — красный полигон, положительные — фиолетовый.
|
||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
import base64
|
import base64
|
||||||
@@ -18,73 +17,97 @@ def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520
|
|||||||
"""
|
"""
|
||||||
n = len(items)
|
n = len(items)
|
||||||
if n < 3:
|
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>'
|
return (
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">'
|
||||||
|
f'<text x="{size//2}" y="{size//2}" text-anchor="middle" fill="#888" '
|
||||||
|
f'font-family="Arial,sans-serif" font-size="16">Нет данных</text></svg>'
|
||||||
|
)
|
||||||
|
|
||||||
cx = cy = size / 2
|
cx = size / 2
|
||||||
R = size * 0.27 # радиус внешнего кольца
|
cy = size / 2
|
||||||
lbl_r = R + size * 0.12 # расстояние до подписей от центра
|
R = size * 0.255
|
||||||
safe_max = max_val if max_val > 0 else 1.0
|
lbl_r = R + size * 0.105
|
||||||
|
|
||||||
|
vals = [it["value"] for it in items]
|
||||||
|
max_abs = max((abs(v) for v in vals), default=1.0)
|
||||||
|
if max_val > 0:
|
||||||
|
max_abs = max(max_abs, max_val)
|
||||||
|
max_abs = max(max_abs, 1.0)
|
||||||
|
|
||||||
def axis_xy(i: int, frac: float = 1.0) -> tuple[float, float]:
|
def axis_xy(i: int, frac: float = 1.0) -> tuple[float, float]:
|
||||||
a = math.radians(-90.0 + 360.0 * i / n)
|
a = math.radians(-90.0 + 360.0 * i / n)
|
||||||
return cx + R * frac * math.cos(a), cy + R * frac * math.sin(a)
|
return cx + R * frac * math.cos(a), cy + R * frac * math.sin(a)
|
||||||
|
|
||||||
def poly_pts(pts: list[tuple]) -> str:
|
def poly(pts: list[tuple]) -> str:
|
||||||
return " ".join(f"{x:.2f},{y:.2f}" for x, y in pts)
|
return " ".join(f"{x:.2f},{y:.2f}" for x, y in pts)
|
||||||
|
|
||||||
lines: list[str] = [
|
lines: list[str] = [
|
||||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">',
|
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):
|
for lvl in range(1, 6):
|
||||||
pts = [axis_xy(i, lvl / 5) for i in range(n)]
|
frac = lvl / 5
|
||||||
|
pts = [axis_xy(i, frac) for i in range(n)]
|
||||||
|
alpha = 0.12 + 0.09 * frac
|
||||||
lines.append(
|
lines.append(
|
||||||
f'<polygon points="{poly_pts(pts)}" '
|
f'<polygon points="{poly(pts)}" fill="none" '
|
||||||
f'fill="none" stroke="rgba(244,239,250,0.18)" stroke-width="1"/>'
|
f'stroke="rgba(170,130,230,{alpha:.2f})" stroke-width="1"/>'
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── оси ─────────────────────────────────────────────────────────────────
|
# ── Оси от центра ─────────────────────────────────────────────────────────
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
x, y = axis_xy(i)
|
x, y = axis_xy(i)
|
||||||
lines.append(
|
lines.append(
|
||||||
f'<line x1="{cx:.2f}" y1="{cy:.2f}" x2="{x:.2f}" y2="{y:.2f}" '
|
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"/>'
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── полигон данных ───────────────────────────────────────────────────────
|
# ── Полигоны данных ───────────────────────────────────────────────────────
|
||||||
data_pts = [axis_xy(i, min(it["value"] / safe_max, 1.0)) for i, it in enumerate(items)]
|
neg_pts = [axis_xy(i, max(0.0, min(-it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
|
||||||
lines.append(
|
pos_pts = [axis_xy(i, max(0.0, min( it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
|
||||||
f'<polygon points="{poly_pts(data_pts)}" '
|
|
||||||
f'fill="rgba(155,114,207,0.55)" stroke="#F4EFFA" stroke-width="2.5"/>'
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── точки на вершинах ────────────────────────────────────────────────────
|
lines.append(f'<polygon points="{poly(neg_pts)}" fill="rgba(220,50,50,0.30)" stroke="#FF7070" stroke-width="3.5"/>')
|
||||||
for x, y in data_pts:
|
lines.append(f'<polygon points="{poly(pos_pts)}" fill="rgba(100,60,200,0.32)" stroke="#A080FF" stroke-width="3.5"/>')
|
||||||
lines.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="5" fill="#F4EFFA"/>')
|
|
||||||
|
|
||||||
# ── подписи осей ─────────────────────────────────────────────────────────
|
# ── Точки на вершинах ─────────────────────────────────────────────────────
|
||||||
|
for i, it in enumerate(items):
|
||||||
|
v = it["value"]
|
||||||
|
if abs(v) > 0.05:
|
||||||
|
x, y = axis_xy(i, abs(v) / max_abs)
|
||||||
|
col = "#FF7070" if v < 0 else "#A080FF"
|
||||||
|
lines.append(
|
||||||
|
f'<circle cx="{x:.2f}" cy="{y:.2f}" r="6" fill="{col}" '
|
||||||
|
f'stroke="rgba(255,255,255,0.70)" stroke-width="1.5"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Метки шкалы (первая ось) ──────────────────────────────────────────────
|
||||||
|
for lvl in range(1, 6):
|
||||||
|
frac = lvl / 5
|
||||||
|
tx, ty = axis_xy(0, frac)
|
||||||
|
lines.append(
|
||||||
|
f'<text x="{tx + 4:.2f}" y="{ty - 2:.2f}" font-family="Arial,sans-serif" '
|
||||||
|
f'font-size="9" fill="rgba(170,130,230,0.65)">{max_abs * frac:.0f}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Подписи осей ───────────────────────────────────────────────────────────
|
||||||
for i, item in enumerate(items):
|
for i, item in enumerate(items):
|
||||||
a = math.radians(-90.0 + 360.0 * i / n)
|
a = math.radians(-90.0 + 360.0 * i / n)
|
||||||
lx = cx + lbl_r * math.cos(a)
|
lx = cx + lbl_r * math.cos(a)
|
||||||
ly = cy + lbl_r * math.sin(a)
|
ly = cy + lbl_r * math.sin(a)
|
||||||
col = item.get("dimension_color") or "#F4EFFA"
|
col = item.get("dimension_color") or "#A080FF"
|
||||||
name = _xml_escape(item["dimension_name"])
|
name = _xml_escape(item["dimension_name"])
|
||||||
val = int(item["value"])
|
val = int(item["value"])
|
||||||
|
val_col = "#FF7070" if item["value"] < 0 else "#A080FF"
|
||||||
# название типа (жирный, цветной)
|
|
||||||
lines.append(
|
lines.append(
|
||||||
f'<text x="{lx:.2f}" y="{ly:.2f}" text-anchor="middle" '
|
f'<text x="{lx:.2f}" y="{ly:.2f}" text-anchor="middle" '
|
||||||
f'font-family="Arial,Helvetica,sans-serif" font-size="14" '
|
f'font-family="Arial,Helvetica,sans-serif" font-size="14" '
|
||||||
f'font-weight="bold" fill="{col}">{name}</text>'
|
f'font-weight="bold" fill="{col}">{name}</text>'
|
||||||
)
|
)
|
||||||
# значение (белый)
|
|
||||||
lines.append(
|
lines.append(
|
||||||
f'<text x="{lx:.2f}" y="{ly + 20:.2f}" text-anchor="middle" '
|
f'<text x="{lx:.2f}" y="{ly + 20:.2f}" text-anchor="middle" '
|
||||||
f'font-family="Arial,Helvetica,sans-serif" font-size="13" '
|
f'font-family="Arial,Helvetica,sans-serif" font-size="13" '
|
||||||
f'fill="#F4EFFA">{val}</text>'
|
f'fill="{val_col}">{val}</text>'
|
||||||
)
|
)
|
||||||
|
|
||||||
lines.append("</svg>")
|
lines.append("</svg>")
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class UserView(ft.View):
|
|||||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
controls=[
|
controls=[
|
||||||
ft.Text(
|
ft.Text(
|
||||||
"Добро пожаловать в COPP!",
|
"Добро пожаловать в ЦОПП!",
|
||||||
color=ds.colors.primary,
|
color=ds.colors.primary,
|
||||||
size=ds.s(20),
|
size=ds.s(20),
|
||||||
weight=ft.FontWeight.BOLD,
|
weight=ft.FontWeight.BOLD,
|
||||||
|
|||||||
Reference in New Issue
Block a user