diff --git a/nginx.production.conf b/nginx.production.conf
new file mode 100644
index 0000000..fe3db8f
--- /dev/null
+++ b/nginx.production.conf
@@ -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;
+ }
+}
diff --git a/route/radar_svg_gen.py b/route/radar_svg_gen.py
index 9fef4a6..8389639 100644
--- a/route/radar_svg_gen.py
+++ b/route/radar_svg_gen.py
@@ -5,7 +5,6 @@ radar_svg_gen.py — серверная генерация SVG паутинно
"""
import math
import os
-import base64
def generate_radar_svg(
@@ -18,15 +17,16 @@ def generate_radar_svg(
show_scale_labels: bool = True,
) -> str:
"""
- Возвращает строку SVG паутинной диаграммы.
+ Возвращает SVG паутинной диаграммы.
items — список осей: [{dimension_name, dimension_color, value}, ...]
max_val — максимум шкалы; 0 = авто (вычисляется из items)
size — размер холста в пикселях
scale_padding — запас над максимальным значением (доля, 0.2 = +20%)
- min_scale — минимальное значение шкалы (чтобы маленькие значения не выглядели огромными)
+ min_scale — минимальное значение шкалы
grid_levels — количество концентрических колец
show_scale_labels — рисовать ли подписи значений на одной из осей
+ Отрицательные значения окрашиваются красным, положительные — фиолетовым.
"""
n = len(items)
if n < 3:
@@ -37,40 +37,41 @@ def generate_radar_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)
+ 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 + scale_padding), min_scale, 1.0)
# ---- геометрия ----
- cx = cy = size / 2
- # Радиус сетки: оставляем место для подписей со всех сторон
- lbl_margin = size * 0.25 # запас под подписи
- R = (size / 2) - lbl_margin # сетка заполняет максимум пространства
- lbl_r = R + size * 0.06 # подписи чуть дальше от сетки
+ cx = size / 2
+ cy = size / 2
+ lbl_margin = size * 0.22
+ R = (size / 2) - lbl_margin
+ lbl_r = R + size * 0.07
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:
+ def poly(pts: list[tuple]) -> str:
return " ".join(f"{x:.2f},{y:.2f}" for x, y in pts)
lines: list[str] = [
f'")
diff --git a/web/radar_chart.py b/web/radar_chart.py
index 8681ee5..4a8d7db 100644
--- a/web/radar_chart.py
+++ b/web/radar_chart.py
@@ -1,5 +1,6 @@
"""
-RadarChart — переиспользуемый виджет паутинной диаграммы (ft.Stack).
+RadarChart — переиспользуемый виджет 2D паутинной диаграммы (ft.Stack).
+Отрицательные значения — красный полигон, положительные — фиолетовый.
Параметры:
items — список осей из API /responses/{id}/radar:
@@ -12,13 +13,13 @@ import flet.canvas as cv
import designer as ds
# ── размеры ─────────────────────────────────────────────────────────────────
-_CS = 260 # размер canvas (×2 = итоговая зона графика)
+_CS = 260 # размер canvas
_CX = _CS // 2
_CY = _CS // 2
-_R = 80 # радиус внешнего кольца
-_PAD = 95 # отступ вокруг canvas — оставляем место для подписей
-_LBL_R = _R + 50 # расстояние от центра до центра подписи (в координатах Stack)
-_W_LBL = 130 # ширина контейнера подписи (достаточно для «Конвенциональный»)
+_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]:
@@ -35,56 +36,67 @@ def _closed_path(pts: list[tuple]) -> list:
class RadarChart(ft.Stack):
- """Паутинная диаграмма типов/сфер по данным radar API."""
+ """2D паутинная диаграмма. Отрицательные=красный, позитивные=синий."""
def __init__(self, items: list[dict], max_val: float = 14.0):
- n = len(items)
+ 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)
- # ── сетка (5 уровней) ────────────────────────────────────────────────
+ # ── Сетка ─────────────────────────────────────────────────────────────
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(
_closed_path(ring),
paint=ft.Paint(
style=ft.PaintingStyle.STROKE,
stroke_width=1,
- color="#30F4EFFA",
+ 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="#40F4EFFA"),
+ paint=ft.Paint(stroke_width=1, color="#38F4EFFA"),
))
- # ── полигон данных ───────────────────────────────────────────────────
- safe_max = max_val if max_val > 0 else 1.0
- data_pts = [
- _axis_xy(n, i, min(it["value"] / safe_max, 1.0))
- for i, it in enumerate(items)
- ]
- shapes.append(cv.Path(
- _closed_path(data_pts),
- paint=ft.Paint(style=ft.PaintingStyle.FILL, color="#609B72CF"),
- ))
- shapes.append(cv.Path(
- _closed_path(data_pts),
- paint=ft.Paint(
- style=ft.PaintingStyle.STROKE,
- stroke_width=2.5,
- color="#F4EFFA",
- ),
- ))
- for px, py in data_pts:
- shapes.append(cv.Circle(px, py, 4, paint=ft.Paint(color="#F4EFFA")))
+ # ── Полигоны данных ────────────────────────────────────────────────────
+ 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-координатах ──────────────────────────────────
+ # ── Подписи осей в Stack-координатах ─────────────────────────────────
sw = _CS + 2 * _PAD
sh = _CS + 2 * _PAD
ccx = _PAD + _CX # центр графика в Stack
@@ -92,9 +104,9 @@ class RadarChart(ft.Stack):
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)
+ 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(
@@ -111,7 +123,6 @@ class RadarChart(ft.Stack):
color=col,
text_align=ft.TextAlign.CENTER,
weight=ft.FontWeight.BOLD,
- # разрешаем перенос только по словам (не по символам)
overflow=ft.TextOverflow.FADE,
max_lines=2,
no_wrap=False,
diff --git a/web/radar_svg.py b/web/radar_svg.py
index d59a810..9e4eaf4 100644
--- a/web/radar_svg.py
+++ b/web/radar_svg.py
@@ -1,8 +1,7 @@
"""
-radar_svg.py — генерация SVG паутинной диаграммы для сохранения на устройство.
-
+radar_svg.py — генерация SVG паутинной диаграммы для сохранения.
Не требует сторонних зависимостей (чистый Python).
-SVG содержит корректные шрифты и цвета, может быть открыт/сохранён в браузере.
+Отрицательные значения — красный полигон, положительные — фиолетовый.
"""
import math
import base64
@@ -18,73 +17,97 @@ def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520
"""
n = len(items)
if n < 3:
- return f''
+ return (
+ f''
+ )
- 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
+ cx = size / 2
+ cy = size / 2
+ R = size * 0.255
+ 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]:
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:
+ def poly(pts: list[tuple]) -> str:
return " ".join(f"{x:.2f},{y:.2f}" for x, y in pts)
lines: list[str] = [
f'")
diff --git a/web/views/user_view.py b/web/views/user_view.py
index 3a4623e..d142a73 100644
--- a/web/views/user_view.py
+++ b/web/views/user_view.py
@@ -33,7 +33,7 @@ class UserView(ft.View):
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
controls=[
ft.Text(
- "Добро пожаловать в COPP!",
+ "Добро пожаловать в ЦОПП!",
color=ds.colors.primary,
size=ds.s(20),
weight=ft.FontWeight.BOLD,