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'', ] - # Концентрические сетки + # ── Сетка ───────────────────────────────────────────────────────────────── for lvl in range(1, grid_levels + 1): - frac = lvl / grid_levels - pts = [axis_xy(i, frac) for i in range(n)] - alpha = 0.28 + 0.14 * (lvl / grid_levels) # внешние кольца чуть ярче + frac = lvl / grid_levels + pts = [axis_xy(i, frac) for i in range(n)] + alpha = 0.15 + 0.12 * frac lines.append( - f'' + f'' ) - # Оси от центра + # ── Оси ─────────────────────────────────────────────────────────────────── for i in range(n): x, y = axis_xy(i) lines.append( @@ -78,43 +79,50 @@ def generate_radar_svg( f'stroke="rgba(170,130,230,0.45)" stroke-width="1.2"/>' ) - # Подписи значений на первой оси (вертикально вверх) + # ── Метки шкалы (первая ось) ────────────────────────────────────────────── if show_scale_labels: for lvl in range(1, grid_levels + 1): - frac = lvl / grid_levels - tick_val = safe_max * frac - tx, ty = axis_xy(0, frac) + frac = lvl / grid_levels + tick_val = max_abs * frac + tx, ty = axis_xy(0, frac) lines.append( f'{tick_val:.0f}' + f'fill="rgba(170,130,230,0.70)">{tick_val:.0f}' ) - # Полигон данных - data_pts = [axis_xy(i, min(it["value"] / safe_max, 1.0)) for i, it in enumerate(items)] - lines.append( - f'' - ) + # ── Полигоны данных (красный=отрицательные, синий=положительные) ────────── + neg_pts = [axis_xy(i, max(0.0, min(-it["value"] / max_abs, 1.0))) for i, it in enumerate(items)] + pos_pts = [axis_xy(i, max(0.0, min( it["value"] / max_abs, 1.0))) for i, it in enumerate(items)] - # Точки на вершинах - for x, y in data_pts: - lines.append(f'') + lines.append(f'') + lines.append(f'') - # Подписи осей (с переносом длинных строк по ' — ' или ' ') + # ── Точки ───────────────────────────────────────────────────────────────── + 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'' + ) + + # ── Подписи осей ─────────────────────────────────────────────────────────── font_size = max(11, min(14, int(size / 40))) 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 "#2F184B" + col = item.get("dimension_color") or "#A070D0" val = int(item["value"]) + val_color = "#FF7070" if item["value"] < 0 else "#A080FF" word_lines = _wrap_label(item["dimension_name"]) - # Вертикальное центрирование: сдвигаем вверх на половину суммарной высоты - line_h = font_size + 3 - total_h = line_h * len(word_lines) + (font_size + 2) # +строка со значением - y0 = ly - total_h / 2 + line_h + line_h = font_size + 3 + total_h = line_h * len(word_lines) + (font_size + 2) + y0 = ly - total_h / 2 + line_h for k, part in enumerate(word_lines): lines.append( @@ -125,7 +133,7 @@ def generate_radar_svg( lines.append( f'{val}' + f'font-size="{font_size + 2}" fill="{val_color}">{val}' ) lines.append("") 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'' + 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'', - # фон - f'', ] - # ── концентрические кольца (сетка) ────────────────────────────────────── + # ── Сетка ───────────────────────────────────────────────────────────────── 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( - f'' + f'' ) - # ── оси ───────────────────────────────────────────────────────────────── + # ── Оси от центра ───────────────────────────────────────────────────────── for i in range(n): x, y = axis_xy(i) lines.append( f'' + 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)] - lines.append( - f'' - ) + # ── Полигоны данных ─────────────────────────────────────────────────────── + neg_pts = [axis_xy(i, max(0.0, min(-it["value"] / max_abs, 1.0))) for i, it in enumerate(items)] + pos_pts = [axis_xy(i, max(0.0, min( it["value"] / max_abs, 1.0))) for i, it in enumerate(items)] - # ── точки на вершинах ──────────────────────────────────────────────────── - for x, y in data_pts: - lines.append(f'') + lines.append(f'') + lines.append(f'') - # ── подписи осей ───────────────────────────────────────────────────────── + # ── Точки на вершинах ───────────────────────────────────────────────────── + 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'' + ) + + # ── Метки шкалы (первая ось) ────────────────────────────────────────────── + for lvl in range(1, 6): + frac = lvl / 5 + tx, ty = axis_xy(0, frac) + lines.append( + f'{max_abs * frac:.0f}' + ) + + # ── Подписи осей ─────────────────────────────────────────────────────────── 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" + 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 "#A080FF" name = _xml_escape(item["dimension_name"]) val = int(item["value"]) - - # название типа (жирный, цветной) + val_col = "#FF7070" if item["value"] < 0 else "#A080FF" lines.append( f'{name}' ) - # значение (белый) lines.append( f'{val}' + f'fill="{val_col}">{val}' ) lines.append("") 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,