From 44473a8d9d5feef560e3e6c1cc02f1377a62c5fe Mon Sep 17 00:00:00 2001 From: sysadminix Date: Thu, 5 Feb 2026 18:53:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B1=D0=B8=D0=B7=D0=BD=D0=B5=D1=81=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/account/views/sensors_views.py | 14 +- .../views/sensors_views — копия 2.py | 81 ++ .../migrations/0011_alter_sensortype_code.py | 18 + backend/sitemanagement/models.py | 2 +- .../sitemanagement/models — копия.py | 200 ++++ frontend/app/(protected)/navigation/page.tsx | 38 +- .../navigation/page.tsx — копия 2 | 613 ----------- .../navigation/page.tsx — копия 3 | 615 ----------- .../navigation/page.tsx — копия 4 | 618 ----------- .../navigation/page.tsx — копия 5 | 620 ----------- frontend/app/api/get-detectors/route.ts | 15 +- .../get-detectors/route — копия 2.ts | 117 +++ frontend/components/dashboard/AreaChart.tsx | 10 +- frontend/components/dashboard/BarChart.tsx | 10 +- frontend/components/model/ModelViewer.tsx | 28 +- .../model/ModelViewer.tsx — копия 2 | 66 +- .../model/ModelViewer.tsx — копия 3 | 971 ------------------ .../model/ModelViewer.tsx — копия 4 | 971 ------------------ frontend/components/model/sensorHighlight.ts | 17 +- .../model/sensorHighlight — копия.ts | 96 ++ .../components/navigation/DetectorMenu.tsx | 35 +- .../DetectorMenu.tsx — копия 2 | 296 ------ .../DetectorMenu.tsx — копия 3 | 329 ------ .../DetectorMenu.tsx — копия 4 | 329 ------ 24 files changed, 712 insertions(+), 5397 deletions(-) create mode 100644 backend/api/account/views/sensors_views — копия 2.py create mode 100644 backend/sitemanagement/migrations/0011_alter_sensortype_code.py create mode 100644 backend/sitemanagement/models — копия.py delete mode 100644 frontend/app/(protected)/navigation/page.tsx — копия 2 delete mode 100644 frontend/app/(protected)/navigation/page.tsx — копия 3 delete mode 100644 frontend/app/(protected)/navigation/page.tsx — копия 4 delete mode 100644 frontend/app/(protected)/navigation/page.tsx — копия 5 create mode 100644 frontend/app/api/get-detectors/route — копия 2.ts delete mode 100644 frontend/components/model/ModelViewer.tsx — копия 3 delete mode 100644 frontend/components/model/ModelViewer.tsx — копия 4 create mode 100644 frontend/components/model/sensorHighlight — копия.ts delete mode 100644 frontend/components/navigation/DetectorMenu.tsx — копия 2 delete mode 100644 frontend/components/navigation/DetectorMenu.tsx — копия 3 delete mode 100644 frontend/components/navigation/DetectorMenu.tsx — копия 4 diff --git a/backend/api/account/views/sensors_views.py b/backend/api/account/views/sensors_views.py index 49c1b76..90d518a 100644 --- a/backend/api/account/views/sensors_views.py +++ b/backend/api/account/views/sensors_views.py @@ -44,8 +44,11 @@ class SensorView(APIView): )])}) @handle_exceptions def get(self, request): - """Получение всех датчиков""" + """Получение всех датчиков или датчиков конкретной зоны""" try: + # Получаем опциональный параметр zone_id из query string + zone_id = request.query_params.get('zone_id', None) + sensors = Sensor.objects.select_related( 'sensor_type', 'signal_format' @@ -53,7 +56,14 @@ class SensorView(APIView): 'zones', 'zones__object', 'alerts' - ).all() + ) + + # Фильтруем по зоне если zone_id передан + if zone_id: + sensors = sensors.filter(zones__id=zone_id) + print(f"[SensorView] Filtering by zone_id: {zone_id}") + + sensors = sensors.all() total_count = sensors.count() print(f"[SensorView] Total sensors in DB: {total_count}") diff --git a/backend/api/account/views/sensors_views — копия 2.py b/backend/api/account/views/sensors_views — копия 2.py new file mode 100644 index 0000000..49c1b76 --- /dev/null +++ b/backend/api/account/views/sensors_views — копия 2.py @@ -0,0 +1,81 @@ +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample + +from api.account.serializers.sensor_serializers import DetectorsResponseSerializer +from sitemanagement.models import Sensor + +from api.utils.decorators import handle_exceptions + +@extend_schema(tags=['Датчики']) +class SensorView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = DetectorsResponseSerializer + pagination_class = None # Отключаем пагинацию для получения всех датчиков + + @extend_schema( + summary="Получение всех датчиков", + description="Получение всех датчиков в формате детекторов", + responses={200: OpenApiResponse(response=DetectorsResponseSerializer, description="Датчики успешно получены", + examples=[OpenApiExample( + 'Успешный ответ', + value={ + + "id": 1, + "type": "fire_detector", + "name": "Датчик 1", + "object": "Объект 1", + "status": "warning", + "zone": "Зона 1", + "serial_number": "GA-123", + "floor": 1, + "notifications": [ + { + "id": 1, + "type": "warning", + "timestamp": "2024-01-15T14:30:00Z", + "acknowledged": False, + "priority": "high" + } + ] + } + )])}) + @handle_exceptions + def get(self, request): + """Получение всех датчиков""" + try: + sensors = Sensor.objects.select_related( + 'sensor_type', + 'signal_format' + ).prefetch_related( + 'zones', + 'zones__object', + 'alerts' + ).all() + + total_count = sensors.count() + print(f"[SensorView] Total sensors in DB: {total_count}") + + # Проверяем уникальность serial_number + serial_numbers = [s.serial_number for s in sensors if s.serial_number] + unique_serials = set(serial_numbers) + print(f"[SensorView] Unique serial_numbers: {len(unique_serials)} out of {len(serial_numbers)}") + + if len(serial_numbers) != len(unique_serials): + from collections import Counter + duplicates = {k: v for k, v in Counter(serial_numbers).items() if v > 1} + print(f"[SensorView] WARNING: Found duplicate serial_numbers: {duplicates}") + + serializer = DetectorsResponseSerializer(sensors) + detectors_dict = serializer.data.get('detectors', {}) + + print(f"[SensorView] Serialized detectors count: {len(detectors_dict)}") + print(f"[SensorView] Sample detector_ids: {list(detectors_dict.keys())[:5]}") + + return Response(serializer.data, status=status.HTTP_200_OK) + except Sensor.DoesNotExist: + return Response( + {"error": "Датчики не найдены"}, + status=status.HTTP_404_NOT_FOUND) diff --git a/backend/sitemanagement/migrations/0011_alter_sensortype_code.py b/backend/sitemanagement/migrations/0011_alter_sensortype_code.py new file mode 100644 index 0000000..4da45c2 --- /dev/null +++ b/backend/sitemanagement/migrations/0011_alter_sensortype_code.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-02-03 21:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sitemanagement', '0010_alter_zone_image_path_alter_zone_model_path'), + ] + + operations = [ + migrations.AlterField( + model_name='sensortype', + name='code', + field=models.CharField(max_length=255, unique=True), + ), + ] diff --git a/backend/sitemanagement/models.py b/backend/sitemanagement/models.py index f1ffc35..5e90529 100644 --- a/backend/sitemanagement/models.py +++ b/backend/sitemanagement/models.py @@ -40,7 +40,7 @@ class Channel(models.Model): class SensorType(models.Model): """Тип датчика: GA, PE, GLE""" - code = models.CharField(max_length=10, unique=True) # GA, PE, GLE + code = models.CharField(max_length=255, unique=True) # GA, PE, GLE name = models.CharField(max_length=100) description = models.TextField(blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/backend/sitemanagement/models — копия.py b/backend/sitemanagement/models — копия.py new file mode 100644 index 0000000..f1ffc35 --- /dev/null +++ b/backend/sitemanagement/models — копия.py @@ -0,0 +1,200 @@ +from django.db import models +from decimal import Decimal +from sitemanagement.constants.image_file_path import register_object_upload_path + +class Multiplexor(models.Model): + """Устройство-мультиплексор""" + name = models.CharField(max_length=50, unique=True) + ip = models.GenericIPAddressField(null=True, blank=True) + subnet = models.GenericIPAddressField(null=True, blank=True) + gateway = models.GenericIPAddressField(null=True, blank=True) + sd_path = models.CharField(max_length=255, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + class Meta: + verbose_name = "Мультиплексор" + verbose_name_plural = "Мультиплексоры" + ordering = ["name"] + + def __str__(self): + return self.name + + +class Channel(models.Model): + """Физический канал мультиплексора""" + multiplexor = models.ForeignKey(Multiplexor, on_delete=models.CASCADE, related_name="channels") + number = models.PositiveSmallIntegerField() # CH-1 ... CH-14 + # для "полканалов" можно использовать дробное значение (1, 1.5, 2, ...) + position = models.DecimalField(max_digits=4, decimal_places=1, default=Decimal("1.0")) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + class Meta: + unique_together = ("multiplexor", "number", "position") + verbose_name = "Канал" + verbose_name_plural = "Каналы" + ordering = ["multiplexor", "number", "position"] + + def __str__(self): + return f"{self.multiplexor.name} - CH{self.number} ({self.position})" + + +class SensorType(models.Model): + """Тип датчика: GA, PE, GLE""" + code = models.CharField(max_length=10, unique=True) # GA, PE, GLE + name = models.CharField(max_length=100) + description = models.TextField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + # пороговые значения для всех сенсоров этого типа + min_value = models.DecimalField(max_digits=12, decimal_places=4, null=True, blank=True) + max_value = models.DecimalField(max_digits=12, decimal_places=4, null=True, blank=True) + + class Meta: + verbose_name = "Тип сенсора" + verbose_name_plural = "Типы сенсоров" + ordering = ["name"] + + def __str__(self): + return self.code + + +class SignalFormat(models.Model): + """Формат сигнала и правило преобразования""" + sensor_type = models.ForeignKey(SensorType, on_delete=models.CASCADE, related_name="formats") + code = models.CharField(max_length=50, verbose_name="Код") # например "4-20мА", "VW f<1600Hz", "NTC R>250Ohm" + unit = models.CharField(max_length=20, blank=True, null=True, verbose_name="Единица измерения") # °C, мкм/м, мм и т.п. + conversion_rule = models.CharField(max_length=255, blank=True, null=True, verbose_name="Правило преобразования") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + class Meta: + unique_together = ("sensor_type", "code") + verbose_name = "Формат сигнала" + verbose_name_plural = "Форматы сигналов" + ordering = ["sensor_type", "code"] + + def __str__(self): + return f"{self.sensor_type.code} - {self.code}" + + +class Sensor(models.Model): + """Конкретный датчик, установленный в канале""" + channel = models.ForeignKey(Channel, on_delete=models.CASCADE, related_name="sensors") + sensor_type = models.ForeignKey(SensorType, on_delete=models.CASCADE, verbose_name="Тип сенсора") + signal_format = models.ForeignKey(SignalFormat, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Формат сигнала") + serial_number = models.CharField(max_length=50, blank=True, null=True, verbose_name="Серийный номер") # CL 2106009 + name = models.CharField(max_length=50, blank=True, null=True, verbose_name="Название") # GA-1, HLE-1 и т.п. + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + math_formula = models.CharField(null=True, blank=True, max_length=255, verbose_name="Математическая формула") + + class Meta: + verbose_name = "Датчик" + verbose_name_plural = "Датчики" + ordering = ["channel", "sensor_type", "serial_number"] + + def __str__(self): + return f"{self.name or self.sensor_type.code} ({self.serial_number})" + + +class Metric(models.Model): + """Значения, которые приходят из CSV""" + timestamp = models.DateTimeField() + sensor = models.ForeignKey(Sensor, on_delete=models.CASCADE, related_name="metrics", verbose_name="Датчик") + raw_value = models.CharField(max_length=50, verbose_name="Исходное значение") # исходное значение из файла (например "11.964 (A)") + value = models.FloatField(null=True, blank=True, verbose_name="Преобразованное значение") # преобразованное значение + status = models.CharField(max_length=20, blank=True, null=True, verbose_name="Статус") # No Rx, Error, NotAv и т.д. + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Метрика" + verbose_name_plural = "Метрики" + ordering = ["timestamp", "sensor"] + indexes = [ + models.Index(fields=["timestamp"]), + models.Index(fields=["sensor"]), + ] + + def __str__(self): + return f"{self.timestamp} {self.sensor} = {self.value} {self.sensor.signal_format.unit if self.sensor.signal_format else ''}" + + +class Alert(models.Model): + """Тревоги по метрикам""" + sensor = models.ForeignKey(Sensor, on_delete=models.CASCADE, related_name="alerts", verbose_name="Датчик") + metric = models.ForeignKey(Metric, on_delete=models.CASCADE, related_name="alerts", verbose_name="Метрика") + sensor_type = models.ForeignKey(SensorType, on_delete=models.CASCADE, related_name="alerts", verbose_name="Тип сенсора") + message = models.CharField(max_length=255, verbose_name="Сообщение") + severity = models.CharField( + max_length=20, + choices=[ + ("warning", "Warning"), + ("critical", "Critical"), + ], + default="warning", + verbose_name="Уровень тревоги" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + resolved = models.BooleanField(default=False, verbose_name="Статус обработки") + + class Meta: + indexes = [ + models.Index(fields=["created_at"]), + models.Index(fields=["sensor"]), + models.Index(fields=["sensor_type"]), + ] + verbose_name = "Тревога" + verbose_name_plural = "Тревоги" + ordering = ["created_at", "sensor"] + + def __str__(self): + return f"ALERT {self.sensor} @ {self.metric.timestamp}: {self.message}" + +class Object(models.Model): + """Объект""" + title = models.CharField(max_length=255, verbose_name="Название") + description = models.TextField(blank=True, null=True, verbose_name="Описание") + image = models.ImageField(upload_to=register_object_upload_path, null=True, blank=True, verbose_name="Изображение") + address = models.CharField(max_length=255, verbose_name="Адрес") + floors = models.PositiveSmallIntegerField(verbose_name="Количество этажей") + area = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Площадь") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Объект" + verbose_name_plural = "Объекты" + ordering = ["title"] + + def __str__(self): + return self.title + +class Zone(models.Model): + """Зона""" + object = models.ForeignKey(Object, on_delete=models.CASCADE, related_name="zones", verbose_name="Объект") + name = models.CharField(max_length=255, verbose_name="Название") + floor = models.PositiveSmallIntegerField(verbose_name="Этаж") + image_path = models.CharField(max_length=255, verbose_name="Путь к изображению", blank=True, default="test_image.png", help_text="Например 'test_image_2.png'") + model_path = models.CharField(max_length=255, verbose_name="Путь к 3D модели", blank=True, null=True, help_text="Например '/static-models/AerBIM-Monitor_ASM-HTViewer_Expo2017Astana_20250908_L_+76190.glb'") + order = models.PositiveSmallIntegerField(default=0, verbose_name="Порядок") + sensors = models.ManyToManyField(Sensor, related_name="zones", verbose_name="Датчики") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Зона" + verbose_name_plural = "Зоны" + ordering = ["object", "floor", "order", "name"] # сортировка по объекту, этажу, порядку, названию + + def clean(self): + from django.core.exceptions import ValidationError + # проверяем что номер этажа не превышает количество этажей в здании + if self.floor > self.object.floors: + raise ValidationError({ + 'floor': f'Номер этажа не может быть больше количества этажей в здании ({self.object.floors})' + }) + + def __str__(self): + return f"{self.object.title} - Этаж {self.floor} - {self.name}" + \ No newline at end of file diff --git a/frontend/app/(protected)/navigation/page.tsx b/frontend/app/(protected)/navigation/page.tsx index 9af0103..1c02517 100644 --- a/frontend/app/(protected)/navigation/page.tsx +++ b/frontend/app/(protected)/navigation/page.tsx @@ -215,7 +215,41 @@ const NavigationPage: React.FC = () => { const loadDetectors = async () => { try { setDetectorsError(null) - const res = await fetch('/api/get-detectors', { cache: 'no-store' }) + + // Если есть modelPath и objectId - фильтруем по зоне + let zoneId: string | null = null + if (selectedModelPath && objectId) { + try { + // Получаем зоны для объекта + const zonesRes = await fetch(`/api/get-zones?objectId=${objectId}`, { cache: 'no-store' }) + if (zonesRes.ok) { + const zonesResponse = await zonesRes.json() + // API возвращает { success: true, data: [...] } + const zonesData = zonesResponse?.data || zonesResponse + console.log('[NavigationPage] Loaded zones:', { count: Array.isArray(zonesData) ? zonesData.length : 0, zonesData }) + // Ищем зону по model_path + if (Array.isArray(zonesData)) { + const zone = zonesData.find((z: any) => z.model_path === selectedModelPath) + if (zone) { + zoneId = zone.id + console.log('[NavigationPage] Found zone for model_path:', { modelPath: selectedModelPath, zoneId }) + } else { + console.log('[NavigationPage] No zone found for model_path:', selectedModelPath) + } + } + } + } catch (e) { + console.warn('[NavigationPage] Failed to load zones for filtering:', e) + } + } + + // Загружаем датчики (с фильтром по зоне если найдена) + const detectorsUrl = zoneId + ? `/api/get-detectors?zone_id=${zoneId}` + : '/api/get-detectors' + console.log('[NavigationPage] Loading detectors from:', detectorsUrl) + + const res = await fetch(detectorsUrl, { cache: 'no-store' }) const text = await res.text() let payload: any try { payload = JSON.parse(text) } catch { payload = text } @@ -232,7 +266,7 @@ const NavigationPage: React.FC = () => { } } loadDetectors() - }, []) + }, [selectedModelPath, objectId]) const handleBackClick = () => { router.push('/dashboard') diff --git a/frontend/app/(protected)/navigation/page.tsx — копия 2 b/frontend/app/(protected)/navigation/page.tsx — копия 2 deleted file mode 100644 index 8941094..0000000 --- a/frontend/app/(protected)/navigation/page.tsx — копия 2 +++ /dev/null @@ -1,613 +0,0 @@ -'use client' - -import React, { useEffect, useCallback, useState } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import Image from 'next/image' -import Sidebar from '../../../components/ui/Sidebar' -import AnimatedBackground from '../../../components/ui/AnimatedBackground' -import useNavigationStore from '../../store/navigationStore' -import Monitoring from '../../../components/navigation/Monitoring' -import FloorNavigation from '../../../components/navigation/FloorNavigation' -import DetectorMenu from '../../../components/navigation/DetectorMenu' -import ListOfDetectors from '../../../components/navigation/ListOfDetectors' -import Sensors from '../../../components/navigation/Sensors' -import AlertMenu from '../../../components/navigation/AlertMenu' -import Notifications from '../../../components/notifications/Notifications' -import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo' -import dynamic from 'next/dynamic' -import type { ModelViewerProps } from '../../../components/model/ModelViewer' -import * as statusColors from '../../../lib/statusColors' - -const ModelViewer = dynamic(() => import('../../../components/model/ModelViewer'), { - ssr: false, - loading: () => ( -
-
Загрузка 3D-модуля…
-
- ), - }) - -interface DetectorType { - detector_id: number - name: string - serial_number: string - object: string - status: string - checked: boolean - type: string - detector_type: string - location: string - floor: number - notifications: Array<{ - id: number - type: string - message: string - timestamp: string - acknowledged: boolean - priority: string - }> -} - -interface NotificationType { - id: number - detector_id: number - detector_name: string - type: string - status: string - message: string - timestamp: string - location: string - object: string - acknowledged: boolean - priority: string -} - -interface AlertType { - id: number - detector_id: number - detector_name: string - type: string - status: string - message: string - timestamp: string - location: string - object: string - acknowledged: boolean - priority: string -} - -const NavigationPage: React.FC = () => { - const router = useRouter() - const searchParams = useSearchParams() - const { - currentObject, - setCurrentObject, - showMonitoring, - showFloorNavigation, - showNotifications, - showListOfDetectors, - showSensors, - selectedDetector, - showDetectorMenu, - selectedNotification, - showNotificationDetectorInfo, - selectedAlert, - showAlertMenu, - closeMonitoring, - closeFloorNavigation, - closeNotifications, - closeListOfDetectors, - closeSensors, - setSelectedDetector, - setShowDetectorMenu, - setSelectedNotification, - setShowNotificationDetectorInfo, - setSelectedAlert, - setShowAlertMenu - } = useNavigationStore() - - const [detectorsData, setDetectorsData] = useState<{ detectors: Record }>({ detectors: {} }) - const [detectorsError, setDetectorsError] = useState(null) - const [modelError, setModelError] = useState(null) - const [isModelReady, setIsModelReady] = useState(false) - const [focusedSensorId, setFocusedSensorId] = useState(null) - const [highlightAllSensors, setHighlightAllSensors] = useState(false) - const sensorStatusMap = React.useMemo(() => { - const map: Record = {} - Object.values(detectorsData.detectors).forEach(d => { - if (d.serial_number && d.status) { - map[String(d.serial_number).trim()] = d.status - } - }) - console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors') - console.log('[NavigationPage] Sample sensor IDs in map:', Object.keys(map).slice(0, 5)) - return map - }, [detectorsData]) - - useEffect(() => { - if (selectedDetector === null && selectedAlert === null) { - setFocusedSensorId(null); - } - }, [selectedDetector, selectedAlert]); - - // Управление выделением всех сенсоров при открытии/закрытии меню Sensors - // ИСПРАВЛЕНО: Подсветка датчиков остается включенной всегда, независимо от состояния панели Sensors - useEffect(() => { - console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady) - if (isModelReady) { - // Всегда включаем подсветку всех сенсоров когда модель готова - console.log('[NavigationPage] Setting highlightAllSensors to TRUE (always enabled)') - setHighlightAllSensors(true) - // Сбрасываем фокус только если панель Sensors закрыта - if (!showSensors) { - setFocusedSensorId(null) - } - } - }, [showSensors, isModelReady]) - - // Дополнительный эффект для задержки выделения сенсоров при открытии меню - // ИСПРАВЛЕНО: Задержка применяется только при открытии панели Sensors - useEffect(() => { - if (showSensors && isModelReady) { - const timer = setTimeout(() => { - console.log('[NavigationPage] Delayed highlightAllSensors to TRUE') - setHighlightAllSensors(true) - }, 500) // Задержка 500мс для полной инициализации модели - - return () => clearTimeout(timer) - } - }, [showSensors, isModelReady]) - - const urlObjectId = searchParams.get('objectId') - const urlObjectTitle = searchParams.get('objectTitle') - const urlModelPath = searchParams.get('modelPath') - const objectId = currentObject.id || urlObjectId - const objectTitle = currentObject.title || urlObjectTitle - const [selectedModelPath, setSelectedModelPath] = useState(urlModelPath || '') - - const handleModelLoaded = useCallback(() => { - setIsModelReady(true) - setModelError(null) - }, []) - - const handleModelError = useCallback((error: string) => { - console.error('[NavigationPage] Model loading error:', error) - setModelError(error) - setIsModelReady(false) - }, []) - - useEffect(() => { - if (selectedModelPath) { - setIsModelReady(false); - setModelError(null); - // Сохраняем выбранную модель в URL для восстановления при возврате - const params = new URLSearchParams(searchParams.toString()); - params.set('modelPath', selectedModelPath); - window.history.replaceState(null, '', `?${params.toString()}`); - } - }, [selectedModelPath, searchParams]); - - useEffect(() => { - if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) { - setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined) - } - }, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject]) - - // Восстановление выбранной модели из URL при загрузке страницы - useEffect(() => { - if (urlModelPath && !selectedModelPath) { - setSelectedModelPath(urlModelPath); - } - }, [urlModelPath, selectedModelPath]) - - useEffect(() => { - const loadDetectors = async () => { - try { - setDetectorsError(null) - const res = await fetch('/api/get-detectors', { cache: 'no-store' }) - const text = await res.text() - let payload: any - try { payload = JSON.parse(text) } catch { payload = text } - console.log('[NavigationPage] GET /api/get-detectors', { status: res.status, payload }) - if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов')) - const data = payload?.data ?? payload - const detectors = (data?.detectors ?? {}) as Record - console.log('[NavigationPage] Received detectors count:', Object.keys(detectors).length) - console.log('[NavigationPage] Sample detector keys:', Object.keys(detectors).slice(0, 5)) - setDetectorsData({ detectors }) - } catch (e: any) { - console.error('Ошибка загрузки детекторов:', e) - setDetectorsError(e?.message || 'Ошибка при загрузке детекторов') - } - } - loadDetectors() - }, []) - - const handleBackClick = () => { - router.push('/dashboard') - } - - const handleDetectorMenuClick = (detector: DetectorType) => { - // Для тестов. Выбор детектора. - console.log('[NavigationPage] Selected detector click:', { - detector_id: detector.detector_id, - name: detector.name, - serial_number: detector.serial_number, - }) - - // Проверяем, что детектор имеет необходимые данные - if (!detector || !detector.detector_id || !detector.serial_number) { - console.warn('[NavigationPage] Invalid detector data, skipping menu display:', detector) - return - } - - if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) { - closeDetectorMenu() - } else { - setSelectedDetector(detector) - setShowDetectorMenu(true) - setFocusedSensorId(detector.serial_number) - setShowAlertMenu(false) - setSelectedAlert(null) - // При открытии меню детектора - сбрасываем множественное выделение - setHighlightAllSensors(false) - } - } - - const closeDetectorMenu = () => { - setShowDetectorMenu(false) - setSelectedDetector(null) - setFocusedSensorId(null) - setSelectedAlert(null) - // При закрытии меню детектора - выделяем все сенсоры снова - setHighlightAllSensors(true) - } - - const handleNotificationClick = (notification: NotificationType) => { - if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) { - setShowNotificationDetectorInfo(false) - setSelectedNotification(null) - } else { - setSelectedNotification(notification) - setShowNotificationDetectorInfo(true) - } - } - - const closeNotificationDetectorInfo = () => { - setShowNotificationDetectorInfo(false) - setSelectedNotification(null) - } - - const closeAlertMenu = () => { - setShowAlertMenu(false) - setSelectedAlert(null) - setFocusedSensorId(null) - setSelectedDetector(null) - // При закрытии меню алерта - выделяем все сенсоры снова - setHighlightAllSensors(true) - } - - const handleAlertClick = (alert: AlertType) => { - console.log('[NavigationPage] Alert clicked, focusing on detector in 3D scene:', alert) - - const detector = Object.values(detectorsData.detectors).find( - d => d.detector_id === alert.detector_id - ) - - if (detector) { - if (selectedAlert?.id === alert.id && showAlertMenu) { - closeAlertMenu() - } else { - setSelectedAlert(alert) - setShowAlertMenu(true) - setFocusedSensorId(detector.serial_number) - setShowDetectorMenu(false) - setSelectedDetector(null) - // При открытии меню алерта - сбрасываем множественное выделение - setHighlightAllSensors(false) - console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name) - } - } else { - console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id) - } - } - - const handleSensorSelection = (serialNumber: string | null) => { - if (serialNumber === null) { - setFocusedSensorId(null); - closeDetectorMenu(); - closeAlertMenu(); - // If we're in Sensors menu and no sensor is selected, highlight all sensors - if (showSensors) { - setHighlightAllSensors(true); - } - return; - } - - if (focusedSensorId === serialNumber) { - setFocusedSensorId(null); - closeDetectorMenu(); - closeAlertMenu(); - // If we're in Sensors menu and deselected the current sensor, highlight all sensors - if (showSensors) { - setHighlightAllSensors(true); - } - return; - } - - // При выборе конкретного сенсора - сбрасываем множественное выделение - setHighlightAllSensors(false) - - const detector = Object.values(detectorsData?.detectors || {}).find( - (d) => d.serial_number === serialNumber - ); - - if (detector) { - if (showFloorNavigation || showListOfDetectors) { - handleDetectorMenuClick(detector); - } else if (detector.notifications && detector.notifications.length > 0) { - const sortedNotifications = [...detector.notifications].sort((a, b) => { - const priorityOrder: { [key: string]: number } = { critical: 0, warning: 1, info: 2 }; - return priorityOrder[a.priority.toLowerCase()] - priorityOrder[b.priority.toLowerCase()]; - }); - const notification = sortedNotifications[0]; - const alert: AlertType = { - ...notification, - detector_id: detector.detector_id, - detector_name: detector.name, - location: detector.location, - object: detector.object, - status: detector.status, - type: notification.type || 'info', - }; - handleAlertClick(alert); - } else { - handleDetectorMenuClick(detector); - } - } else { - setFocusedSensorId(null); - closeDetectorMenu(); - closeAlertMenu(); - // If we're in Sensors menu and no valid detector found, highlight all sensors - if (showSensors) { - setHighlightAllSensors(true); - } - } - }; - - const getStatusText = (status: string) => { - const s = (status || '').toLowerCase() - switch (s) { - case statusColors.STATUS_COLOR_CRITICAL: - case 'critical': - return 'Критический' - case statusColors.STATUS_COLOR_WARNING: - case 'warning': - return 'Предупреждение' - case statusColors.STATUS_COLOR_NORMAL: - case 'normal': - return 'Норма' - default: - return 'Неизвестно' - } - } - - return ( -
- -
- -
- -
- - {showMonitoring && ( -
-
- { - console.log('[NavigationPage] Model selected:', path); - setSelectedModelPath(path) - setModelError(null) - setIsModelReady(false) - }} - /> -
-
- )} - - {showFloorNavigation && ( -
-
- -
-
- )} - - {showNotifications && ( -
-
- - {detectorsError && ( -
{detectorsError}
- )} -
-
- )} - - {showListOfDetectors && ( -
-
- - {detectorsError && ( -
{detectorsError}
- )} -
-
- )} - - {showSensors && ( -
-
- - {detectorsError && ( -
{detectorsError}
- )} -
-
- )} - - {showNotifications && showNotificationDetectorInfo && selectedNotification && (() => { - const detectorData = Object.values(detectorsData.detectors).find( - detector => detector.detector_id === selectedNotification.detector_id - ); - return detectorData ? ( -
-
- -
-
- ) : null; - })()} - - {showFloorNavigation && showDetectorMenu && selectedDetector && ( - null - )} - -
-
-
- - -
-
-
- -
-
- {modelError ? ( - <> - {console.log('[NavigationPage] Rendering error message, modelError:', modelError)} -
-
-
- Ошибка загрузки 3D модели -
-
- {modelError} -
-
- Используйте навигацию по этажам для просмотра детекторов -
-
-
- - ) : !selectedModelPath ? ( -
-
- AerBIM HT Monitor -
- Выберите модель для отображения -
-
-
- ) : ( - { - console.log('[NavigationPage] Model selected:', path); - setSelectedModelPath(path) - setModelError(null) - setIsModelReady(false) - }} - onModelLoaded={handleModelLoaded} - onError={handleModelError} - activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null} - focusSensorId={focusedSensorId} - highlightAllSensors={highlightAllSensors} - sensorStatusMap={sensorStatusMap} - isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors} - onSensorPick={handleSensorSelection} - renderOverlay={({ anchor }) => ( - <> - {selectedAlert && showAlertMenu && anchor ? ( - - ) : selectedDetector && showDetectorMenu && anchor ? ( - - ) : null} - - )} - /> - )} -
-
-
-
- ) -} - -export default NavigationPage diff --git a/frontend/app/(protected)/navigation/page.tsx — копия 3 b/frontend/app/(protected)/navigation/page.tsx — копия 3 deleted file mode 100644 index b4302f1..0000000 --- a/frontend/app/(protected)/navigation/page.tsx — копия 3 +++ /dev/null @@ -1,615 +0,0 @@ -'use client' - -import React, { useEffect, useCallback, useState } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import Image from 'next/image' -import Sidebar from '../../../components/ui/Sidebar' -import AnimatedBackground from '../../../components/ui/AnimatedBackground' -import useNavigationStore from '../../store/navigationStore' -import Monitoring from '../../../components/navigation/Monitoring' -import FloorNavigation from '../../../components/navigation/FloorNavigation' -import DetectorMenu from '../../../components/navigation/DetectorMenu' -import ListOfDetectors from '../../../components/navigation/ListOfDetectors' -import Sensors from '../../../components/navigation/Sensors' -import AlertMenu from '../../../components/navigation/AlertMenu' -import Notifications from '../../../components/notifications/Notifications' -import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo' -import dynamic from 'next/dynamic' -import type { ModelViewerProps } from '../../../components/model/ModelViewer' -import * as statusColors from '../../../lib/statusColors' - -const ModelViewer = dynamic(() => import('../../../components/model/ModelViewer'), { - ssr: false, - loading: () => ( -
-
Загрузка 3D-модуля…
-
- ), - }) - -interface DetectorType { - detector_id: number - name: string - serial_number: string - object: string - status: string - checked: boolean - type: string - detector_type: string - location: string - floor: number - notifications: Array<{ - id: number - type: string - message: string - timestamp: string - acknowledged: boolean - priority: string - }> -} - -interface NotificationType { - id: number - detector_id: number - detector_name: string - type: string - status: string - message: string - timestamp: string - location: string - object: string - acknowledged: boolean - priority: string -} - -interface AlertType { - id: number - detector_id: number - detector_name: string - type: string - status: string - message: string - timestamp: string - location: string - object: string - acknowledged: boolean - priority: string -} - -const NavigationPage: React.FC = () => { - const router = useRouter() - const searchParams = useSearchParams() - const { - currentObject, - setCurrentObject, - showMonitoring, - showFloorNavigation, - showNotifications, - showListOfDetectors, - showSensors, - selectedDetector, - showDetectorMenu, - selectedNotification, - showNotificationDetectorInfo, - selectedAlert, - showAlertMenu, - closeMonitoring, - closeFloorNavigation, - closeNotifications, - closeListOfDetectors, - closeSensors, - setSelectedDetector, - setShowDetectorMenu, - setSelectedNotification, - setShowNotificationDetectorInfo, - setSelectedAlert, - setShowAlertMenu, - showSensorHighlights, - toggleSensorHighlights - } = useNavigationStore() - - const [detectorsData, setDetectorsData] = useState<{ detectors: Record }>({ detectors: {} }) - const [detectorsError, setDetectorsError] = useState(null) - const [modelError, setModelError] = useState(null) - const [isModelReady, setIsModelReady] = useState(false) - const [focusedSensorId, setFocusedSensorId] = useState(null) - const [highlightAllSensors, setHighlightAllSensors] = useState(false) - const sensorStatusMap = React.useMemo(() => { - const map: Record = {} - Object.values(detectorsData.detectors).forEach(d => { - if (d.serial_number && d.status) { - map[String(d.serial_number).trim()] = d.status - } - }) - console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors') - console.log('[NavigationPage] Sample sensor IDs in map:', Object.keys(map).slice(0, 5)) - return map - }, [detectorsData]) - - useEffect(() => { - if (selectedDetector === null && selectedAlert === null) { - setFocusedSensorId(null); - } - }, [selectedDetector, selectedAlert]); - - // Управление выделением всех сенсоров при открытии/закрытии меню Sensors - // ИСПРАВЛЕНО: Подсветка датчиков остается включенной всегда, независимо от состояния панели Sensors - useEffect(() => { - console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady) - if (isModelReady) { - // Всегда включаем подсветку всех сенсоров когда модель готова - console.log('[NavigationPage] Setting highlightAllSensors to TRUE (always enabled)') - setHighlightAllSensors(true) - // Сбрасываем фокус только если панель Sensors закрыта - if (!showSensors) { - setFocusedSensorId(null) - } - } - }, [showSensors, isModelReady]) - - // Дополнительный эффект для задержки выделения сенсоров при открытии меню - // ИСПРАВЛЕНО: Задержка применяется только при открытии панели Sensors - useEffect(() => { - if (showSensors && isModelReady) { - const timer = setTimeout(() => { - console.log('[NavigationPage] Delayed highlightAllSensors to TRUE') - setHighlightAllSensors(true) - }, 500) // Задержка 500мс для полной инициализации модели - - return () => clearTimeout(timer) - } - }, [showSensors, isModelReady]) - - const urlObjectId = searchParams.get('objectId') - const urlObjectTitle = searchParams.get('objectTitle') - const urlModelPath = searchParams.get('modelPath') - const objectId = currentObject.id || urlObjectId - const objectTitle = currentObject.title || urlObjectTitle - const [selectedModelPath, setSelectedModelPath] = useState(urlModelPath || '') - - const handleModelLoaded = useCallback(() => { - setIsModelReady(true) - setModelError(null) - }, []) - - const handleModelError = useCallback((error: string) => { - console.error('[NavigationPage] Model loading error:', error) - setModelError(error) - setIsModelReady(false) - }, []) - - useEffect(() => { - if (selectedModelPath) { - setIsModelReady(false); - setModelError(null); - // Сохраняем выбранную модель в URL для восстановления при возврате - const params = new URLSearchParams(searchParams.toString()); - params.set('modelPath', selectedModelPath); - window.history.replaceState(null, '', `?${params.toString()}`); - } - }, [selectedModelPath, searchParams]); - - useEffect(() => { - if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) { - setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined) - } - }, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject]) - - // Восстановление выбранной модели из URL при загрузке страницы - useEffect(() => { - if (urlModelPath && !selectedModelPath) { - setSelectedModelPath(urlModelPath); - } - }, [urlModelPath, selectedModelPath]) - - useEffect(() => { - const loadDetectors = async () => { - try { - setDetectorsError(null) - const res = await fetch('/api/get-detectors', { cache: 'no-store' }) - const text = await res.text() - let payload: any - try { payload = JSON.parse(text) } catch { payload = text } - console.log('[NavigationPage] GET /api/get-detectors', { status: res.status, payload }) - if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов')) - const data = payload?.data ?? payload - const detectors = (data?.detectors ?? {}) as Record - console.log('[NavigationPage] Received detectors count:', Object.keys(detectors).length) - console.log('[NavigationPage] Sample detector keys:', Object.keys(detectors).slice(0, 5)) - setDetectorsData({ detectors }) - } catch (e: any) { - console.error('Ошибка загрузки детекторов:', e) - setDetectorsError(e?.message || 'Ошибка при загрузке детекторов') - } - } - loadDetectors() - }, []) - - const handleBackClick = () => { - router.push('/dashboard') - } - - const handleDetectorMenuClick = (detector: DetectorType) => { - // Для тестов. Выбор детектора. - console.log('[NavigationPage] Selected detector click:', { - detector_id: detector.detector_id, - name: detector.name, - serial_number: detector.serial_number, - }) - - // Проверяем, что детектор имеет необходимые данные - if (!detector || !detector.detector_id || !detector.serial_number) { - console.warn('[NavigationPage] Invalid detector data, skipping menu display:', detector) - return - } - - if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) { - closeDetectorMenu() - } else { - setSelectedDetector(detector) - setShowDetectorMenu(true) - setFocusedSensorId(detector.serial_number) - setShowAlertMenu(false) - setSelectedAlert(null) - // При открытии меню детектора - сбрасываем множественное выделение - setHighlightAllSensors(false) - } - } - - const closeDetectorMenu = () => { - setShowDetectorMenu(false) - setSelectedDetector(null) - setFocusedSensorId(null) - setSelectedAlert(null) - // При закрытии меню детектора - выделяем все сенсоры снова - setHighlightAllSensors(true) - } - - const handleNotificationClick = (notification: NotificationType) => { - if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) { - setShowNotificationDetectorInfo(false) - setSelectedNotification(null) - } else { - setSelectedNotification(notification) - setShowNotificationDetectorInfo(true) - } - } - - const closeNotificationDetectorInfo = () => { - setShowNotificationDetectorInfo(false) - setSelectedNotification(null) - } - - const closeAlertMenu = () => { - setShowAlertMenu(false) - setSelectedAlert(null) - setFocusedSensorId(null) - setSelectedDetector(null) - // При закрытии меню алерта - выделяем все сенсоры снова - setHighlightAllSensors(true) - } - - const handleAlertClick = (alert: AlertType) => { - console.log('[NavigationPage] Alert clicked, focusing on detector in 3D scene:', alert) - - const detector = Object.values(detectorsData.detectors).find( - d => d.detector_id === alert.detector_id - ) - - if (detector) { - if (selectedAlert?.id === alert.id && showAlertMenu) { - closeAlertMenu() - } else { - setSelectedAlert(alert) - setShowAlertMenu(true) - setFocusedSensorId(detector.serial_number) - setShowDetectorMenu(false) - setSelectedDetector(null) - // При открытии меню алерта - сбрасываем множественное выделение - setHighlightAllSensors(false) - console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name) - } - } else { - console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id) - } - } - - const handleSensorSelection = (serialNumber: string | null) => { - if (serialNumber === null) { - setFocusedSensorId(null); - closeDetectorMenu(); - closeAlertMenu(); - // If we're in Sensors menu and no sensor is selected, highlight all sensors - if (showSensors) { - setHighlightAllSensors(true); - } - return; - } - - if (focusedSensorId === serialNumber) { - setFocusedSensorId(null); - closeDetectorMenu(); - closeAlertMenu(); - // If we're in Sensors menu and deselected the current sensor, highlight all sensors - if (showSensors) { - setHighlightAllSensors(true); - } - return; - } - - // При выборе конкретного сенсора - сбрасываем множественное выделение - setHighlightAllSensors(false) - - const detector = Object.values(detectorsData?.detectors || {}).find( - (d) => d.serial_number === serialNumber - ); - - if (detector) { - if (showFloorNavigation || showListOfDetectors) { - handleDetectorMenuClick(detector); - } else if (detector.notifications && detector.notifications.length > 0) { - const sortedNotifications = [...detector.notifications].sort((a, b) => { - const priorityOrder: { [key: string]: number } = { critical: 0, warning: 1, info: 2 }; - return priorityOrder[a.priority.toLowerCase()] - priorityOrder[b.priority.toLowerCase()]; - }); - const notification = sortedNotifications[0]; - const alert: AlertType = { - ...notification, - detector_id: detector.detector_id, - detector_name: detector.name, - location: detector.location, - object: detector.object, - status: detector.status, - type: notification.type || 'info', - }; - handleAlertClick(alert); - } else { - handleDetectorMenuClick(detector); - } - } else { - setFocusedSensorId(null); - closeDetectorMenu(); - closeAlertMenu(); - // If we're in Sensors menu and no valid detector found, highlight all sensors - if (showSensors) { - setHighlightAllSensors(true); - } - } - }; - - const getStatusText = (status: string) => { - const s = (status || '').toLowerCase() - switch (s) { - case statusColors.STATUS_COLOR_CRITICAL: - case 'critical': - return 'Критический' - case statusColors.STATUS_COLOR_WARNING: - case 'warning': - return 'Предупреждение' - case statusColors.STATUS_COLOR_NORMAL: - case 'normal': - return 'Норма' - default: - return 'Неизвестно' - } - } - - return ( -
- -
- -
- -
- - {showMonitoring && ( -
-
- { - console.log('[NavigationPage] Model selected:', path); - setSelectedModelPath(path) - setModelError(null) - setIsModelReady(false) - }} - /> -
-
- )} - - {showFloorNavigation && ( -
-
- -
-
- )} - - {showNotifications && ( -
-
- - {detectorsError && ( -
{detectorsError}
- )} -
-
- )} - - {showListOfDetectors && ( -
-
- - {detectorsError && ( -
{detectorsError}
- )} -
-
- )} - - {showSensors && ( -
-
- - {detectorsError && ( -
{detectorsError}
- )} -
-
- )} - - {showNotifications && showNotificationDetectorInfo && selectedNotification && (() => { - const detectorData = Object.values(detectorsData.detectors).find( - detector => detector.detector_id === selectedNotification.detector_id - ); - return detectorData ? ( -
-
- -
-
- ) : null; - })()} - - {showFloorNavigation && showDetectorMenu && selectedDetector && ( - null - )} - -
-
-
- - -
-
-
- -
-
- {modelError ? ( - <> - {console.log('[NavigationPage] Rendering error message, modelError:', modelError)} -
-
-
- Ошибка загрузки 3D модели -
-
- {modelError} -
-
- Используйте навигацию по этажам для просмотра детекторов -
-
-
- - ) : !selectedModelPath ? ( -
-
- AerBIM HT Monitor -
- Выберите модель для отображения -
-
-
- ) : ( - { - console.log('[NavigationPage] Model selected:', path); - setSelectedModelPath(path) - setModelError(null) - setIsModelReady(false) - }} - onModelLoaded={handleModelLoaded} - onError={handleModelError} - activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null} - focusSensorId={focusedSensorId} - highlightAllSensors={showSensorHighlights && highlightAllSensors} - sensorStatusMap={sensorStatusMap} - isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors} - onSensorPick={handleSensorSelection} - renderOverlay={({ anchor }) => ( - <> - {selectedAlert && showAlertMenu && anchor ? ( - - ) : selectedDetector && showDetectorMenu && anchor ? ( - - ) : null} - - )} - /> - )} -
-
-
-
- ) -} - -export default NavigationPage diff --git a/frontend/app/(protected)/navigation/page.tsx — копия 4 b/frontend/app/(protected)/navigation/page.tsx — копия 4 deleted file mode 100644 index 5264c75..0000000 --- a/frontend/app/(protected)/navigation/page.tsx — копия 4 +++ /dev/null @@ -1,618 +0,0 @@ -'use client' - -import React, { useEffect, useCallback, useState } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import Image from 'next/image' -import Sidebar from '../../../components/ui/Sidebar' -import AnimatedBackground from '../../../components/ui/AnimatedBackground' -import useNavigationStore from '../../store/navigationStore' -import Monitoring from '../../../components/navigation/Monitoring' -import FloorNavigation from '../../../components/navigation/FloorNavigation' -import DetectorMenu from '../../../components/navigation/DetectorMenu' -import ListOfDetectors from '../../../components/navigation/ListOfDetectors' -import Sensors from '../../../components/navigation/Sensors' -import AlertMenu from '../../../components/navigation/AlertMenu' -import Notifications from '../../../components/notifications/Notifications' -import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo' -import dynamic from 'next/dynamic' -import type { ModelViewerProps } from '../../../components/model/ModelViewer' -import * as statusColors from '../../../lib/statusColors' - -const ModelViewer = dynamic(() => import('../../../components/model/ModelViewer'), { - ssr: false, - loading: () => ( -
-
Загрузка 3D-модуля…
-
- ), - }) - -interface DetectorType { - detector_id: number - name: string - serial_number: string - object: string - status: string - checked: boolean - type: string - detector_type: string - location: string - floor: number - notifications: Array<{ - id: number - type: string - message: string - timestamp: string - acknowledged: boolean - priority: string - }> -} - -interface NotificationType { - id: number - detector_id: number - detector_name: string - type: string - status: string - message: string - timestamp: string - location: string - object: string - acknowledged: boolean - priority: string -} - -interface AlertType { - id: number - detector_id: number - detector_name: string - type: string - status: string - message: string - timestamp: string - location: string - object: string - acknowledged: boolean - priority: string -} - -const NavigationPage: React.FC = () => { - const router = useRouter() - const searchParams = useSearchParams() - const { - currentObject, - setCurrentObject, - showMonitoring, - showFloorNavigation, - showNotifications, - showListOfDetectors, - showSensors, - selectedDetector, - showDetectorMenu, - selectedNotification, - showNotificationDetectorInfo, - selectedAlert, - showAlertMenu, - closeMonitoring, - closeFloorNavigation, - closeNotifications, - closeListOfDetectors, - closeSensors, - setSelectedDetector, - setShowDetectorMenu, - setSelectedNotification, - setShowNotificationDetectorInfo, - setSelectedAlert, - setShowAlertMenu, - showSensorHighlights, - toggleSensorHighlights - } = useNavigationStore() - - const [detectorsData, setDetectorsData] = useState<{ detectors: Record }>({ detectors: {} }) - const [detectorsError, setDetectorsError] = useState(null) - const [modelError, setModelError] = useState(null) - const [isModelReady, setIsModelReady] = useState(false) - const [focusedSensorId, setFocusedSensorId] = useState(null) - const [highlightAllSensors, setHighlightAllSensors] = useState(false) - const sensorStatusMap = React.useMemo(() => { - // Создаём карту статусов всегда для отображения цветов датчиков - const map: Record = {} - Object.values(detectorsData.detectors).forEach(d => { - if (d.serial_number && d.status) { - map[String(d.serial_number).trim()] = d.status - } - }) - console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors') - console.log('[NavigationPage] Sample sensor IDs in map:', Object.keys(map).slice(0, 5)) - return map - }, [detectorsData]) - - useEffect(() => { - if (selectedDetector === null && selectedAlert === null) { - setFocusedSensorId(null); - } - }, [selectedDetector, selectedAlert]); - - // Управление выделением всех сенсоров при открытии/закрытии меню Sensors - // ИСПРАВЛЕНО: Подсветка датчиков остается включенной всегда, независимо от состояния панели Sensors - useEffect(() => { - console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady) - if (isModelReady) { - // Всегда включаем подсветку всех сенсоров когда модель готова - console.log('[NavigationPage] Setting highlightAllSensors to TRUE (always enabled)') - setHighlightAllSensors(true) - // Сбрасываем фокус только если панель Sensors закрыта - if (!showSensors) { - setFocusedSensorId(null) - } - } - }, [showSensors, isModelReady]) - - // Дополнительный эффект для задержки выделения сенсоров при открытии меню - // ИСПРАВЛЕНО: Задержка применяется только при открытии панели Sensors - useEffect(() => { - if (showSensors && isModelReady) { - const timer = setTimeout(() => { - console.log('[NavigationPage] Delayed highlightAllSensors to TRUE') - setHighlightAllSensors(true) - }, 500) // Задержка 500мс для полной инициализации модели - - return () => clearTimeout(timer) - } - }, [showSensors, isModelReady]) - - const urlObjectId = searchParams.get('objectId') - const urlObjectTitle = searchParams.get('objectTitle') - const urlModelPath = searchParams.get('modelPath') - const urlFocusSensorId = searchParams.get('focusSensorId') - const objectId = currentObject.id || urlObjectId - const objectTitle = currentObject.title || urlObjectTitle - const [selectedModelPath, setSelectedModelPath] = useState(urlModelPath || '') - - - const handleModelLoaded = useCallback(() => { - setIsModelReady(true) - setModelError(null) - }, []) - - const handleModelError = useCallback((error: string) => { - console.error('[NavigationPage] Model loading error:', error) - setModelError(error) - setIsModelReady(false) - }, []) - - useEffect(() => { - if (selectedModelPath) { - setIsModelReady(false); - setModelError(null); - // Сохраняем выбранную модель в URL для восстановления при возврате - const params = new URLSearchParams(searchParams.toString()); - params.set('modelPath', selectedModelPath); - window.history.replaceState(null, '', `?${params.toString()}`); - } - }, [selectedModelPath, searchParams]); - - useEffect(() => { - if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) { - setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined) - } - }, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject]) - - // Восстановление выбранной модели из URL при загрузке страницы - useEffect(() => { - if (urlModelPath && !selectedModelPath) { - setSelectedModelPath(urlModelPath); - } - }, [urlModelPath, selectedModelPath]) - - useEffect(() => { - const loadDetectors = async () => { - try { - setDetectorsError(null) - const res = await fetch('/api/get-detectors', { cache: 'no-store' }) - const text = await res.text() - let payload: any - try { payload = JSON.parse(text) } catch { payload = text } - console.log('[NavigationPage] GET /api/get-detectors', { status: res.status, payload }) - if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов')) - const data = payload?.data ?? payload - const detectors = (data?.detectors ?? {}) as Record - console.log('[NavigationPage] Received detectors count:', Object.keys(detectors).length) - console.log('[NavigationPage] Sample detector keys:', Object.keys(detectors).slice(0, 5)) - setDetectorsData({ detectors }) - } catch (e: any) { - console.error('Ошибка загрузки детекторов:', e) - setDetectorsError(e?.message || 'Ошибка при загрузке детекторов') - } - } - loadDetectors() - }, []) - - const handleBackClick = () => { - router.push('/dashboard') - } - - const handleDetectorMenuClick = (detector: DetectorType) => { - // Для тестов. Выбор детектора. - console.log('[NavigationPage] Selected detector click:', { - detector_id: detector.detector_id, - name: detector.name, - serial_number: detector.serial_number, - }) - - // Проверяем, что детектор имеет необходимые данные - if (!detector || !detector.detector_id || !detector.serial_number) { - console.warn('[NavigationPage] Invalid detector data, skipping menu display:', detector) - return - } - - if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) { - closeDetectorMenu() - } else { - setSelectedDetector(detector) - setShowDetectorMenu(true) - setFocusedSensorId(detector.serial_number) - setShowAlertMenu(false) - setSelectedAlert(null) - // При открытии меню детектора - сбрасываем множественное выделение - setHighlightAllSensors(false) - } - } - - const closeDetectorMenu = () => { - setShowDetectorMenu(false) - setSelectedDetector(null) - setFocusedSensorId(null) - setSelectedAlert(null) - // При закрытии меню детектора - выделяем все сенсоры снова - setHighlightAllSensors(true) - } - - const handleNotificationClick = (notification: NotificationType) => { - if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) { - setShowNotificationDetectorInfo(false) - setSelectedNotification(null) - } else { - setSelectedNotification(notification) - setShowNotificationDetectorInfo(true) - } - } - - const closeNotificationDetectorInfo = () => { - setShowNotificationDetectorInfo(false) - setSelectedNotification(null) - } - - const closeAlertMenu = () => { - setShowAlertMenu(false) - setSelectedAlert(null) - setFocusedSensorId(null) - setSelectedDetector(null) - // При закрытии меню алерта - выделяем все сенсоры снова - setHighlightAllSensors(true) - } - - const handleAlertClick = (alert: AlertType) => { - console.log('[NavigationPage] Alert clicked, focusing on detector in 3D scene:', alert) - - const detector = Object.values(detectorsData.detectors).find( - d => d.detector_id === alert.detector_id - ) - - if (detector) { - if (selectedAlert?.id === alert.id && showAlertMenu) { - closeAlertMenu() - } else { - setSelectedAlert(alert) - setShowAlertMenu(true) - setFocusedSensorId(detector.serial_number) - setShowDetectorMenu(false) - setSelectedDetector(null) - // При открытии меню алерта - сбрасываем множественное выделение - setHighlightAllSensors(false) - console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name) - } - } else { - console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id) - } - } - - const handleSensorSelection = (serialNumber: string | null) => { - if (serialNumber === null) { - setFocusedSensorId(null); - closeDetectorMenu(); - closeAlertMenu(); - // If we're in Sensors menu and no sensor is selected, highlight all sensors - if (showSensors) { - setHighlightAllSensors(true); - } - return; - } - - if (focusedSensorId === serialNumber) { - setFocusedSensorId(null); - closeDetectorMenu(); - closeAlertMenu(); - // If we're in Sensors menu and deselected the current sensor, highlight all sensors - if (showSensors) { - setHighlightAllSensors(true); - } - return; - } - - // При выборе конкретного сенсора - сбрасываем множественное выделение - setHighlightAllSensors(false) - - const detector = Object.values(detectorsData?.detectors || {}).find( - (d) => d.serial_number === serialNumber - ); - - if (detector) { - // Всегда показываем меню детектора для всех датчиков - handleDetectorMenuClick(detector); - } else { - setFocusedSensorId(null); - closeDetectorMenu(); - closeAlertMenu(); - // If we're in Sensors menu and no valid detector found, highlight all sensors - if (showSensors) { - setHighlightAllSensors(true); - } - } - }; - - // Обработка focusSensorId из URL (при переходе из таблиц событий) - useEffect(() => { - if (urlFocusSensorId && isModelReady && detectorsData) { - console.log('[NavigationPage] Setting focusSensorId from URL:', urlFocusSensorId) - setFocusedSensorId(urlFocusSensorId) - setHighlightAllSensors(false) - - // Автоматически открываем тултип датчика - setTimeout(() => { - handleSensorSelection(urlFocusSensorId) - }, 500) // Задержка для полной инициализации - - // Очищаем URL от параметра после применения - const newUrl = new URL(window.location.href) - newUrl.searchParams.delete('focusSensorId') - window.history.replaceState({}, '', newUrl.toString()) - } - }, [urlFocusSensorId, isModelReady, detectorsData]) - - const getStatusText = (status: string) => { - const s = (status || '').toLowerCase() - switch (s) { - case statusColors.STATUS_COLOR_CRITICAL: - case 'critical': - return 'Критический' - case statusColors.STATUS_COLOR_WARNING: - case 'warning': - return 'Предупреждение' - case statusColors.STATUS_COLOR_NORMAL: - case 'normal': - return 'Норма' - default: - return 'Неизвестно' - } - } - - return ( -
- -
- -
- -
- - {showMonitoring && ( -
-
- { - console.log('[NavigationPage] Model selected:', path); - setSelectedModelPath(path) - setModelError(null) - setIsModelReady(false) - }} - /> -
-
- )} - - {showFloorNavigation && ( -
-
- -
-
- )} - - {showNotifications && ( -
-
- - {detectorsError && ( -
{detectorsError}
- )} -
-
- )} - - {showListOfDetectors && ( -
-
- - {detectorsError && ( -
{detectorsError}
- )} -
-
- )} - - {showSensors && ( -
-
- - {detectorsError && ( -
{detectorsError}
- )} -
-
- )} - - {showNotifications && showNotificationDetectorInfo && selectedNotification && (() => { - const detectorData = Object.values(detectorsData.detectors).find( - detector => detector.detector_id === selectedNotification.detector_id - ); - return detectorData ? ( -
-
- -
-
- ) : null; - })()} - - {showFloorNavigation && showDetectorMenu && selectedDetector && ( - null - )} - -
-
-
- - -
-
-
- -
-
- {modelError ? ( - <> - {console.log('[NavigationPage] Rendering error message, modelError:', modelError)} -
-
-
- Ошибка загрузки 3D модели -
-
- {modelError} -
-
- Используйте навигацию по этажам для просмотра детекторов -
-
-
- - ) : !selectedModelPath ? ( -
-
- AerBIM HT Monitor -
- Выберите модель для отображения -
-
-
- ) : ( - { - console.log('[NavigationPage] Model selected:', path); - setSelectedModelPath(path) - setModelError(null) - setIsModelReady(false) - }} - onModelLoaded={handleModelLoaded} - onError={handleModelError} - activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null} - focusSensorId={focusedSensorId} - highlightAllSensors={showSensorHighlights && highlightAllSensors} - sensorStatusMap={sensorStatusMap} - isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors} - onSensorPick={handleSensorSelection} - renderOverlay={({ anchor }) => ( - <> - {selectedAlert && showAlertMenu && anchor ? ( - - ) : selectedDetector && showDetectorMenu && anchor ? ( - - ) : null} - - )} - /> - )} -
-
-
-
- ) -} - -export default NavigationPage diff --git a/frontend/app/(protected)/navigation/page.tsx — копия 5 b/frontend/app/(protected)/navigation/page.tsx — копия 5 deleted file mode 100644 index dd64cd0..0000000 --- a/frontend/app/(protected)/navigation/page.tsx — копия 5 +++ /dev/null @@ -1,620 +0,0 @@ -'use client' - -import React, { useEffect, useCallback, useState } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import Image from 'next/image' -import Sidebar from '../../../components/ui/Sidebar' -import AnimatedBackground from '../../../components/ui/AnimatedBackground' -import useNavigationStore from '../../store/navigationStore' -import Monitoring from '../../../components/navigation/Monitoring' -import FloorNavigation from '../../../components/navigation/FloorNavigation' -import DetectorMenu from '../../../components/navigation/DetectorMenu' -import ListOfDetectors from '../../../components/navigation/ListOfDetectors' -import Sensors from '../../../components/navigation/Sensors' -import AlertMenu from '../../../components/navigation/AlertMenu' -import Notifications from '../../../components/notifications/Notifications' -import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo' -import dynamic from 'next/dynamic' -import type { ModelViewerProps } from '../../../components/model/ModelViewer' -import * as statusColors from '../../../lib/statusColors' - -const ModelViewer = dynamic(() => import('../../../components/model/ModelViewer'), { - ssr: false, - loading: () => ( -
-
Загрузка 3D-модуля…
-
- ), - }) - -interface DetectorType { - detector_id: number - name: string - serial_number: string - object: string - status: string - checked: boolean - type: string - detector_type: string - location: string - floor: number - notifications: Array<{ - id: number - type: string - message: string - timestamp: string - acknowledged: boolean - priority: string - }> -} - -interface NotificationType { - id: number - detector_id: number - detector_name: string - type: string - status: string - message: string - timestamp: string - location: string - object: string - acknowledged: boolean - priority: string -} - -interface AlertType { - id: number - detector_id: number - detector_name: string - type: string - status: string - message: string - timestamp: string - location: string - object: string - acknowledged: boolean - priority: string -} - -const NavigationPage: React.FC = () => { - const router = useRouter() - const searchParams = useSearchParams() - const { - currentObject, - setCurrentObject, - showMonitoring, - showFloorNavigation, - showNotifications, - showListOfDetectors, - showSensors, - selectedDetector, - showDetectorMenu, - selectedNotification, - showNotificationDetectorInfo, - selectedAlert, - showAlertMenu, - closeMonitoring, - closeFloorNavigation, - closeNotifications, - closeListOfDetectors, - closeSensors, - setSelectedDetector, - setShowDetectorMenu, - setSelectedNotification, - setShowNotificationDetectorInfo, - setSelectedAlert, - setShowAlertMenu, - showSensorHighlights, - toggleSensorHighlights - } = useNavigationStore() - - const [detectorsData, setDetectorsData] = useState<{ detectors: Record }>({ detectors: {} }) - const [detectorsError, setDetectorsError] = useState(null) - const [modelError, setModelError] = useState(null) - const [isModelReady, setIsModelReady] = useState(false) - const [focusedSensorId, setFocusedSensorId] = useState(null) - const [highlightAllSensors, setHighlightAllSensors] = useState(false) - const [showStats, setShowStats] = useState(false) - const sensorStatusMap = React.useMemo(() => { - const map: Record = {} - Object.values(detectorsData.detectors).forEach(d => { - if (d.serial_number && d.status) { - map[String(d.serial_number).trim()] = d.status - } - }) - console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors') - console.log('[NavigationPage] Sample sensor IDs in map:', Object.keys(map).slice(0, 5)) - return map - }, [detectorsData]) - - useEffect(() => { - if (selectedDetector === null && selectedAlert === null) { - setFocusedSensorId(null); - } - }, [selectedDetector, selectedAlert]); - - // Управление выделением всех сенсоров при открытии/закрытии меню Sensors - // ИСПРАВЛЕНО: Подсветка датчиков остается включенной всегда, независимо от состояния панели Sensors - useEffect(() => { - console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady) - if (isModelReady) { - // Всегда включаем подсветку всех сенсоров когда модель готова - console.log('[NavigationPage] Setting highlightAllSensors to TRUE (always enabled)') - setHighlightAllSensors(true) - // Сбрасываем фокус только если панель Sensors закрыта - if (!showSensors) { - setFocusedSensorId(null) - } - } - }, [showSensors, isModelReady]) - - // Дополнительный эффект для задержки выделения сенсоров при открытии меню - // ИСПРАВЛЕНО: Задержка применяется только при открытии панели Sensors - useEffect(() => { - if (showSensors && isModelReady) { - const timer = setTimeout(() => { - console.log('[NavigationPage] Delayed highlightAllSensors to TRUE') - setHighlightAllSensors(true) - }, 500) // Задержка 500мс для полной инициализации модели - - return () => clearTimeout(timer) - } - }, [showSensors, isModelReady]) - - const urlObjectId = searchParams.get('objectId') - const urlObjectTitle = searchParams.get('objectTitle') - const urlModelPath = searchParams.get('modelPath') - const urlFocusSensorId = searchParams.get('focusSensorId') - const objectId = currentObject.id || urlObjectId - const objectTitle = currentObject.title || urlObjectTitle - const [selectedModelPath, setSelectedModelPath] = useState(urlModelPath || '') - - - const handleModelLoaded = useCallback(() => { - setIsModelReady(true) - setModelError(null) - }, []) - - const handleModelError = useCallback((error: string) => { - console.error('[NavigationPage] Model loading error:', error) - setModelError(error) - setIsModelReady(false) - }, []) - - useEffect(() => { - if (selectedModelPath) { - setIsModelReady(false); - setModelError(null); - // Сохраняем выбранную модель в URL для восстановления при возврате - const params = new URLSearchParams(searchParams.toString()); - params.set('modelPath', selectedModelPath); - window.history.replaceState(null, '', `?${params.toString()}`); - } - }, [selectedModelPath, searchParams]); - - useEffect(() => { - if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) { - setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined) - } - }, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject]) - - // Восстановление выбранной модели из URL при загрузке страницы - useEffect(() => { - if (urlModelPath && !selectedModelPath) { - setSelectedModelPath(urlModelPath); - } - }, [urlModelPath, selectedModelPath]) - - useEffect(() => { - const loadDetectors = async () => { - try { - setDetectorsError(null) - const res = await fetch('/api/get-detectors', { cache: 'no-store' }) - const text = await res.text() - let payload: any - try { payload = JSON.parse(text) } catch { payload = text } - console.log('[NavigationPage] GET /api/get-detectors', { status: res.status, payload }) - if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов')) - const data = payload?.data ?? payload - const detectors = (data?.detectors ?? {}) as Record - console.log('[NavigationPage] Received detectors count:', Object.keys(detectors).length) - console.log('[NavigationPage] Sample detector keys:', Object.keys(detectors).slice(0, 5)) - setDetectorsData({ detectors }) - } catch (e: any) { - console.error('Ошибка загрузки детекторов:', e) - setDetectorsError(e?.message || 'Ошибка при загрузке детекторов') - } - } - loadDetectors() - }, []) - - const handleBackClick = () => { - router.push('/dashboard') - } - - const handleDetectorMenuClick = (detector: DetectorType) => { - // Для тестов. Выбор детектора. - console.log('[NavigationPage] Selected detector click:', { - detector_id: detector.detector_id, - name: detector.name, - serial_number: detector.serial_number, - }) - - // Проверяем, что детектор имеет необходимые данные - if (!detector || !detector.detector_id || !detector.serial_number) { - console.warn('[NavigationPage] Invalid detector data, skipping menu display:', detector) - return - } - - if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) { - closeDetectorMenu() - } else { - setSelectedDetector(detector) - setShowDetectorMenu(true) - setFocusedSensorId(detector.serial_number) - setShowAlertMenu(false) - setSelectedAlert(null) - // При открытии меню детектора - сбрасываем множественное выделение - setHighlightAllSensors(false) - } - } - - const closeDetectorMenu = () => { - setShowDetectorMenu(false) - setSelectedDetector(null) - setFocusedSensorId(null) - setSelectedAlert(null) - // При закрытии меню детектора - выделяем все сенсоры снова - setHighlightAllSensors(true) - } - - const handleNotificationClick = (notification: NotificationType) => { - if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) { - setShowNotificationDetectorInfo(false) - setSelectedNotification(null) - } else { - setSelectedNotification(notification) - setShowNotificationDetectorInfo(true) - } - } - - const closeNotificationDetectorInfo = () => { - setShowNotificationDetectorInfo(false) - setSelectedNotification(null) - } - - const closeAlertMenu = () => { - setShowAlertMenu(false) - setSelectedAlert(null) - setFocusedSensorId(null) - setSelectedDetector(null) - // При закрытии меню алерта - выделяем все сенсоры снова - setHighlightAllSensors(true) - } - - const handleAlertClick = (alert: AlertType) => { - console.log('[NavigationPage] Alert clicked, focusing on detector in 3D scene:', alert) - - const detector = Object.values(detectorsData.detectors).find( - d => d.detector_id === alert.detector_id - ) - - if (detector) { - if (selectedAlert?.id === alert.id && showAlertMenu) { - closeAlertMenu() - } else { - setSelectedAlert(alert) - setShowAlertMenu(true) - setFocusedSensorId(detector.serial_number) - setShowDetectorMenu(false) - setSelectedDetector(null) - // При открытии меню алерта - сбрасываем множественное выделение - setHighlightAllSensors(false) - console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name) - } - } else { - console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id) - } - } - - const handleSensorSelection = (serialNumber: string | null) => { - if (serialNumber === null) { - setFocusedSensorId(null); - closeDetectorMenu(); - closeAlertMenu(); - // If we're in Sensors menu and no sensor is selected, highlight all sensors - if (showSensors) { - setHighlightAllSensors(true); - } - return; - } - - if (focusedSensorId === serialNumber) { - setFocusedSensorId(null); - closeDetectorMenu(); - closeAlertMenu(); - // If we're in Sensors menu and deselected the current sensor, highlight all sensors - if (showSensors) { - setHighlightAllSensors(true); - } - return; - } - - // При выборе конкретного сенсора - сбрасываем множественное выделение - setHighlightAllSensors(false) - - const detector = Object.values(detectorsData?.detectors || {}).find( - (d) => d.serial_number === serialNumber - ); - - if (detector) { - // Всегда показываем меню детектора для всех датчиков - handleDetectorMenuClick(detector); - } else { - setFocusedSensorId(null); - closeDetectorMenu(); - closeAlertMenu(); - // If we're in Sensors menu and no valid detector found, highlight all sensors - if (showSensors) { - setHighlightAllSensors(true); - } - } - }; - - // Обработка focusSensorId из URL (при переходе из таблиц событий) - useEffect(() => { - if (urlFocusSensorId && isModelReady && detectorsData) { - console.log('[NavigationPage] Setting focusSensorId from URL:', urlFocusSensorId) - setFocusedSensorId(urlFocusSensorId) - setHighlightAllSensors(false) - - // Автоматически открываем тултип датчика - setTimeout(() => { - handleSensorSelection(urlFocusSensorId) - }, 500) // Задержка для полной инициализации - - // Очищаем URL от параметра после применения - const newUrl = new URL(window.location.href) - newUrl.searchParams.delete('focusSensorId') - window.history.replaceState({}, '', newUrl.toString()) - } - }, [urlFocusSensorId, isModelReady, detectorsData]) - - const getStatusText = (status: string) => { - const s = (status || '').toLowerCase() - switch (s) { - case statusColors.STATUS_COLOR_CRITICAL: - case 'critical': - return 'Критический' - case statusColors.STATUS_COLOR_WARNING: - case 'warning': - return 'Предупреждение' - case statusColors.STATUS_COLOR_NORMAL: - case 'normal': - return 'Норма' - default: - return 'Неизвестно' - } - } - - return ( -
- -
- -
- -
- - {showMonitoring && ( -
-
- { - console.log('[NavigationPage] Model selected:', path); - setSelectedModelPath(path) - setModelError(null) - setIsModelReady(false) - }} - /> -
-
- )} - - {showFloorNavigation && ( -
-
- -
-
- )} - - {showNotifications && ( -
-
- - {detectorsError && ( -
{detectorsError}
- )} -
-
- )} - - {showListOfDetectors && ( -
-
- - {detectorsError && ( -
{detectorsError}
- )} -
-
- )} - - {showSensors && ( -
-
- - {detectorsError && ( -
{detectorsError}
- )} -
-
- )} - - {showNotifications && showNotificationDetectorInfo && selectedNotification && (() => { - const detectorData = Object.values(detectorsData.detectors).find( - detector => detector.detector_id === selectedNotification.detector_id - ); - return detectorData ? ( -
-
- -
-
- ) : null; - })()} - - {showFloorNavigation && showDetectorMenu && selectedDetector && ( - null - )} - -
-
-
- - -
-
-
- -
-
- {modelError ? ( - <> - {console.log('[NavigationPage] Rendering error message, modelError:', modelError)} -
-
-
- Ошибка загрузки 3D модели -
-
- {modelError} -
-
- Используйте навигацию по этажам для просмотра детекторов -
-
-
- - ) : !selectedModelPath ? ( -
-
- AerBIM HT Monitor -
- Выберите модель для отображения -
-
-
- ) : ( - { - console.log('[NavigationPage] Model selected:', path); - setSelectedModelPath(path) - setModelError(null) - setIsModelReady(false) - }} - onModelLoaded={handleModelLoaded} - onError={handleModelError} - activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null} - focusSensorId={focusedSensorId} - highlightAllSensors={showSensorHighlights && highlightAllSensors} - sensorStatusMap={sensorStatusMap} - isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors} - onSensorPick={handleSensorSelection} - showStats={showStats} - onToggleStats={() => setShowStats(!showStats)} - renderOverlay={({ anchor }) => ( - <> - {selectedAlert && showAlertMenu && anchor ? ( - - ) : selectedDetector && showDetectorMenu && anchor ? ( - - ) : null} - - )} - /> - )} -
-
-
-
- ) -} - -export default NavigationPage diff --git a/frontend/app/api/get-detectors/route.ts b/frontend/app/api/get-detectors/route.ts index f9f2483..930ac0c 100644 --- a/frontend/app/api/get-detectors/route.ts +++ b/frontend/app/api/get-detectors/route.ts @@ -3,7 +3,7 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import * as statusColors from '@/lib/statusColors' -export async function GET() { +export async function GET(request: Request) { try { const session = await getServerSession(authOptions) if (!session?.accessToken) { @@ -11,9 +11,20 @@ export async function GET() { } const backendUrl = process.env.BACKEND_URL + + // Получаем zone_id из query параметров + const { searchParams } = new URL(request.url) + const zoneId = searchParams.get('zone_id') + + // Формируем URL для бэкенда с zone_id если он есть + const detectorsUrl = zoneId + ? `${backendUrl}/account/get-detectors/?zone_id=${zoneId}` + : `${backendUrl}/account/get-detectors/` + + console.log('[get-detectors] Fetching from backend:', detectorsUrl) const [detectorsRes, objectsRes] = await Promise.all([ - fetch(`${backendUrl}/account/get-detectors/`, { + fetch(detectorsUrl, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}`, diff --git a/frontend/app/api/get-detectors/route — копия 2.ts b/frontend/app/api/get-detectors/route — копия 2.ts new file mode 100644 index 0000000..f9f2483 --- /dev/null +++ b/frontend/app/api/get-detectors/route — копия 2.ts @@ -0,0 +1,117 @@ +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import * as statusColors from '@/lib/statusColors' + +export async function GET() { + try { + const session = await getServerSession(authOptions) + if (!session?.accessToken) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const backendUrl = process.env.BACKEND_URL + + const [detectorsRes, objectsRes] = await Promise.all([ + fetch(`${backendUrl}/account/get-detectors/`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.accessToken}`, + }, + cache: 'no-store', + }), + fetch(`${backendUrl}/account/get-objects/`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.accessToken}`, + }, + cache: 'no-store', + }), + ]) + + if (!detectorsRes.ok) { + const err = await detectorsRes.text() + return NextResponse.json({ success: false, error: `Backend detectors error: ${err}` }, { status: detectorsRes.status }) + } + if (!objectsRes.ok) { + const err = await objectsRes.text() + return NextResponse.json({ success: false, error: `Backend objects error: ${err}` }, { status: objectsRes.status }) + } + + const detectorsPayload = await detectorsRes.json() + const objectsPayload = await objectsRes.json() + + const titleToIdMap: Record = {} + if (Array.isArray(objectsPayload)) { + for (const obj of objectsPayload) { + if (obj && typeof obj.title === 'string' && typeof obj.id === 'number') { + titleToIdMap[obj.title] = `object_${obj.id}` + } + } + } + + const statusToColor: Record = { + critical: statusColors.STATUS_COLOR_CRITICAL, + warning: statusColors.STATUS_COLOR_WARNING, + normal: statusColors.STATUS_COLOR_NORMAL, + } + + const transformedDetectors: Record = {} + const detectorsObj = detectorsPayload?.detectors ?? {} + for (const [key, sensor] of Object.entries(detectorsObj)) { + const color = statusToColor[sensor.status] ?? statusColors.STATUS_COLOR_NORMAL + const objectId = titleToIdMap[sensor.object] || sensor.object + transformedDetectors[key] = { + ...sensor, + status: color, + object: objectId, + checked: sensor.checked ?? false, + location: sensor.zone ?? '', + serial_number: sensor.serial_number ?? sensor.name ?? '', + detector_type: sensor.detector_type ?? '', + notifications: Array.isArray(sensor.notifications) ? sensor.notifications.map((n: any) => { + const severity = String(n?.severity || n?.type || '').toLowerCase() + + // Логируем оригинальные данные для отладки + if (sensor.serial_number === 'GLE-1') { + console.log('[get-detectors] Original notification for GLE-1:', { severity: n?.severity, type: n?.type, message: n?.message }) + } + + // Добавляем поддержку русских названий + let type = 'info' + if (severity === 'critical' || severity === 'критический' || severity === 'критичный') { + type = 'critical' + } else if (severity === 'warning' || severity === 'предупреждение') { + type = 'warning' + } + + const priority = type === 'critical' ? 'high' : type === 'warning' ? 'medium' : 'low' + return { + id: n.id, + type, + message: n.message, + timestamp: n.timestamp || n.created_at, + acknowledged: typeof n.acknowledged === 'boolean' ? n.acknowledged : !!n.resolved, + priority, + } + }) : [] + } + } + + return NextResponse.json({ + success: true, + data: { detectors: transformedDetectors }, + objectsCount: Array.isArray(objectsPayload) ? objectsPayload.length : 0, + detectorsCount: Object.keys(transformedDetectors).length, + }) + } catch (error) { + console.error('Error fetching detectors data:', error) + return NextResponse.json( + { + success: false, + error: 'Failed to fetch detectors data', + }, + { status: 500 } + ) + } +} diff --git a/frontend/components/dashboard/AreaChart.tsx b/frontend/components/dashboard/AreaChart.tsx index 615ace4..889ac07 100644 --- a/frontend/components/dashboard/AreaChart.tsx +++ b/frontend/components/dashboard/AreaChart.tsx @@ -17,7 +17,7 @@ interface AreaChartProps { const AreaChart: React.FC = ({ className = '', data }) => { const width = 635 const height = 280 - const margin = { top: 20, right: 30, bottom: 50, left: 60 } + const margin = { top: 40, right: 30, bottom: 50, left: 60 } const plotWidth = width - margin.left - margin.right const plotHeight = height - margin.top - margin.bottom const baselineY = margin.top + plotHeight @@ -222,15 +222,15 @@ const AreaChart: React.FC = ({ className = '', data }) => { /> ))} - {/* Легенда */} - + {/* Легенда - горизонтально над графиком */} + Критические - - + + Предупреждения diff --git a/frontend/components/dashboard/BarChart.tsx b/frontend/components/dashboard/BarChart.tsx index c9ebdd7..d3c5214 100644 --- a/frontend/components/dashboard/BarChart.tsx +++ b/frontend/components/dashboard/BarChart.tsx @@ -16,7 +16,7 @@ interface BarChartProps { const BarChart: React.FC = ({ className = '', data }) => { const width = 635 const height = 280 - const margin = { top: 20, right: 30, bottom: 50, left: 60 } + const margin = { top: 40, right: 30, bottom: 50, left: 60 } const plotWidth = width - margin.left - margin.right const plotHeight = height - margin.top - margin.bottom const baselineY = margin.top + plotHeight @@ -240,15 +240,15 @@ const BarChart: React.FC = ({ className = '', data }) => { ) })} - {/* Легенда */} - + {/* Легенда - горизонтально над графиком */} + Критические - - + + Предупреждения diff --git a/frontend/components/model/ModelViewer.tsx b/frontend/components/model/ModelViewer.tsx index a9e0064..2dfa720 100644 --- a/frontend/components/model/ModelViewer.tsx +++ b/frontend/components/model/ModelViewer.tsx @@ -236,7 +236,7 @@ const ModelViewer: React.FC = ({ // Find the mesh for this sensor const allMeshes = importedMeshesRef.current || [] - const sensorMeshes = collectSensorMeshes(allMeshes) + const sensorMeshes = collectSensorMeshes(allMeshes, sensorStatusMap) const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId) if (!targetMesh) { @@ -571,7 +571,7 @@ const ModelViewer: React.FC = ({ } const allMeshes = importedMeshesRef.current || [] - const sensorMeshes = collectSensorMeshes(allMeshes) + const sensorMeshes = collectSensorMeshes(allMeshes, sensorStatusMap) if (sensorMeshes.length === 0) { setAllSensorsOverlayCircles([]) return @@ -609,14 +609,26 @@ const ModelViewer: React.FC = ({ return } - const sensorMeshes = collectSensorMeshes(allMeshes) + // Сначала найдём ВСЕ датчики в 3D модели (без фильтра) + const allSensorMeshesInModel = collectSensorMeshes(allMeshes, null) + const allSensorIdsInModel = allSensorMeshesInModel.map(m => getSensorIdFromMesh(m)).filter(Boolean) + + // Теперь применим фильтр по sensorStatusMap + const sensorMeshes = collectSensorMeshes(allMeshes, sensorStatusMap) + const filteredSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean) console.log('[ModelViewer] Total meshes in model:', allMeshes.length) - console.log('[ModelViewer] Sensor meshes found:', sensorMeshes.length) + console.log('[ModelViewer] ALL sensor meshes in 3D model (unfiltered):', allSensorIdsInModel.length, allSensorIdsInModel) + console.log('[ModelViewer] sensorStatusMap keys count:', sensorStatusMap ? Object.keys(sensorStatusMap).length : 0) + console.log('[ModelViewer] Sensor meshes found (filtered by sensorStatusMap):', sensorMeshes.length, filteredSensorIds) - // Log first 5 sensor IDs found in meshes - const sensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean).slice(0, 5) - console.log('[ModelViewer] Sample sensor IDs from meshes:', sensorIds) + // Найдём датчики которые есть в sensorStatusMap но НЕТ в 3D модели + if (sensorStatusMap) { + const missingInModel = Object.keys(sensorStatusMap).filter(id => !allSensorIdsInModel.includes(id)) + if (missingInModel.length > 0) { + console.warn('[ModelViewer] Sensors in sensorStatusMap but MISSING in 3D model:', missingInModel.length, missingInModel.slice(0, 10)) + } + } if (sensorMeshes.length === 0) { console.warn('[ModelViewer] No sensor meshes found in 3D model!') @@ -670,7 +682,7 @@ const ModelViewer: React.FC = ({ return } - const sensorMeshes = collectSensorMeshes(allMeshes) + const sensorMeshes = collectSensorMeshes(allMeshes, sensorStatusMap) const allSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)) const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId) diff --git a/frontend/components/model/ModelViewer.tsx — копия 2 b/frontend/components/model/ModelViewer.tsx — копия 2 index 2ee6469..a9e0064 100644 --- a/frontend/components/model/ModelViewer.tsx — копия 2 +++ b/frontend/components/model/ModelViewer.tsx — копия 2 @@ -20,11 +20,13 @@ import { PointerEventTypes, PointerInfo, Matrix, + Ray, } from '@babylonjs/core' import '@babylonjs/loaders' import SceneToolbar from './SceneToolbar'; import LoadingSpinner from '../ui/LoadingSpinner' +import useNavigationStore from '@/app/store/navigationStore' import { getSensorIdFromMesh, collectSensorMeshes, @@ -67,6 +69,8 @@ const ModelViewer: React.FC = ({ onSensorPick, highlightAllSensors, sensorStatusMap, + showStats = false, + onToggleStats, }) => { const canvasRef = useRef(null) const engineRef = useRef>(null) @@ -339,7 +343,8 @@ const ModelViewer: React.FC = ({ let engine: Engine try { - engine = new Engine(canvas, true, { stencil: true }) + // Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU + engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) const message = `WebGL недоступен: ${errorMessage}` @@ -361,6 +366,9 @@ const ModelViewer: React.FC = ({ sceneRef.current = scene scene.clearColor = new Color4(0.1, 0.1, 0.15, 1) + + // Оптимизация: включаем FXAA (более легковесное сглаживание) + scene.imageProcessingConfiguration.fxaaEnabled = true const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene) camera.attachControl(canvas, true) @@ -688,16 +696,65 @@ const ModelViewer: React.FC = ({ const maxDimension = Math.max(size.x, size.y, size.z) const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) + // Простое позиционирование камеры - всегда поворачиваемся к датчику + console.log('[ModelViewer] Calculating camera direction to sensor') + + // Вычисляем направление от текущей позиции камеры к датчику + const directionToSensor = center.subtract(camera.position).normalize() + + // Преобразуем в сферические координаты + // alpha - горизонтальный угол (вокруг оси Y) + let targetAlpha = Math.atan2(directionToSensor.x, directionToSensor.z) + + // beta - вертикальный угол (от вертикали) + // Используем оптимальный угол 60° для обзора + let targetBeta = Math.PI / 3 // 60° + + console.log('[ModelViewer] Calculated camera direction:', { + alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°', + beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°', + sensorPosition: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) }, + cameraPosition: { x: camera.position.x.toFixed(2), y: camera.position.y.toFixed(2), z: camera.position.z.toFixed(2) } + }) + + // Нормализуем alpha в диапазон [-PI, PI] + while (targetAlpha > Math.PI) targetAlpha -= 2 * Math.PI + while (targetAlpha < -Math.PI) targetAlpha += 2 * Math.PI + + // Ограничиваем beta в разумных пределах + targetBeta = Math.max(0.1, Math.min(Math.PI - 0.1, targetBeta)) + scene.stopAnimation(camera) + // Логирование перед анимацией + console.log('[ModelViewer] Starting camera animation:', { + sensorId, + from: { + target: { x: camera.target.x.toFixed(2), y: camera.target.y.toFixed(2), z: camera.target.z.toFixed(2) }, + radius: camera.radius.toFixed(2), + alpha: (camera.alpha * 180 / Math.PI).toFixed(1) + '°', + beta: (camera.beta * 180 / Math.PI).toFixed(1) + '°' + }, + to: { + target: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) }, + radius: targetRadius.toFixed(2), + alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°', + beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°' + }, + alphaChange: ((targetAlpha - camera.alpha) * 180 / Math.PI).toFixed(1) + '°', + betaChange: ((targetBeta - camera.beta) * 180 / Math.PI).toFixed(1) + '°' + }) + const ease = new CubicEase() ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) const frameRate = 60 - const durationMs = 600 + const durationMs = 800 const totalFrames = Math.round((durationMs / 1000) * frameRate) Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease) Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) + Animation.CreateAndStartAnimation('camAlpha', camera, 'alpha', frameRate, totalFrames, camera.alpha, targetAlpha, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) + Animation.CreateAndStartAnimation('camBeta', camera, 'beta', frameRate, totalFrames, camera.beta, targetBeta, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) applyHighlightToMeshes( highlightLayerRef.current, @@ -862,7 +919,10 @@ const ModelViewer: React.FC = ({ onPan={handlePan} onSelectModel={onSelectModel} panActive={panActive} + onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights} + sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights} /> + )} {/* UPDATED: Interactive overlay circles with hover effects */} @@ -908,4 +968,4 @@ const ModelViewer: React.FC = ({ ) } -export default ModelViewer +export default React.memo(ModelViewer) diff --git a/frontend/components/model/ModelViewer.tsx — копия 3 b/frontend/components/model/ModelViewer.tsx — копия 3 deleted file mode 100644 index 943f55b..0000000 --- a/frontend/components/model/ModelViewer.tsx — копия 3 +++ /dev/null @@ -1,971 +0,0 @@ -'use client' - -import React, { useEffect, useRef, useState } from 'react' - -import { - Engine, - Scene, - Vector3, - HemisphericLight, - ArcRotateCamera, - Color3, - Color4, - AbstractMesh, - Nullable, - HighlightLayer, - Animation, - CubicEase, - EasingFunction, - ImportMeshAsync, - PointerEventTypes, - PointerInfo, - Matrix, - Ray, -} from '@babylonjs/core' -import '@babylonjs/loaders' - -import SceneToolbar from './SceneToolbar'; -import LoadingSpinner from '../ui/LoadingSpinner' -import useNavigationStore from '@/app/store/navigationStore' -import { - getSensorIdFromMesh, - collectSensorMeshes, - applyHighlightToMeshes, - statusToColor3, -} from './sensorHighlight' -import { - computeSensorOverlayCircles, - hexWithAlpha, -} from './sensorHighlightOverlay' - -export interface ModelViewerProps { - modelPath: string - onSelectModel: (path: string) => void; - onModelLoaded?: (modelData: { - meshes: AbstractMesh[] - boundingBox: { - min: { x: number; y: number; z: number } - max: { x: number; y: number; z: number } - } - }) => void - onError?: (error: string) => void - activeMenu?: string | null - focusSensorId?: string | null - renderOverlay?: (params: { anchor: { left: number; top: number } | null; info?: { name?: string; sensorId?: string } | null }) => React.ReactNode - isSensorSelectionEnabled?: boolean - onSensorPick?: (sensorId: string | null) => void - highlightAllSensors?: boolean - sensorStatusMap?: Record -} - -const ModelViewer: React.FC = ({ - modelPath, - onSelectModel, - onModelLoaded, - onError, - focusSensorId, - renderOverlay, - isSensorSelectionEnabled, - onSensorPick, - highlightAllSensors, - sensorStatusMap, - showStats = false, - onToggleStats, -}) => { - const canvasRef = useRef(null) - const engineRef = useRef>(null) - const sceneRef = useRef>(null) - const [isLoading, setIsLoading] = useState(false) - const [loadingProgress, setLoadingProgress] = useState(0) - const [showModel, setShowModel] = useState(false) - const isInitializedRef = useRef(false) - const isDisposedRef = useRef(false) - const importedMeshesRef = useRef([]) - const highlightLayerRef = useRef(null) - const highlightedMeshesRef = useRef([]) - const chosenMeshRef = useRef(null) - const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null) - const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null) - const [modelReady, setModelReady] = useState(false) - const [panActive, setPanActive] = useState(false); - const [webglError, setWebglError] = useState(null) - const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState< - { sensorId: string; left: number; top: number; colorHex: string }[] - >([]) - // NEW: State for tracking hovered sensor in overlay circles - const [hoveredSensorId, setHoveredSensorId] = useState(null) - - const handlePan = () => setPanActive(!panActive); - - useEffect(() => { - const scene = sceneRef.current; - const camera = scene?.activeCamera as ArcRotateCamera; - const canvas = canvasRef.current; - - if (!scene || !camera || !canvas) { - return; - } - - let observer: any = null; - - if (panActive) { - camera.detachControl(); - - observer = scene.onPointerObservable.add((pointerInfo: PointerInfo) => { - const evt = pointerInfo.event; - - if (evt.buttons === 1) { - camera.inertialPanningX -= evt.movementX / camera.panningSensibility; - camera.inertialPanningY += evt.movementY / camera.panningSensibility; - } - else if (evt.buttons === 2) { - camera.inertialAlphaOffset -= evt.movementX / camera.angularSensibilityX; - camera.inertialBetaOffset -= evt.movementY / camera.angularSensibilityY; - } - }, PointerEventTypes.POINTERMOVE); - - } else { - camera.detachControl(); - camera.attachControl(canvas, true); - } - - return () => { - if (observer) { - scene.onPointerObservable.remove(observer); - } - if (!camera.isDisposed() && !camera.inputs.attachedToElement) { - camera.attachControl(canvas, true); - } - }; - }, [panActive, sceneRef, canvasRef]); - - const handleZoomIn = () => { - const camera = sceneRef.current?.activeCamera as ArcRotateCamera - if (camera) { - sceneRef.current?.stopAnimation(camera) - const ease = new CubicEase() - ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT) - - const frameRate = 60 - const durationMs = 300 - const totalFrames = Math.round((durationMs / 1000) * frameRate) - - const currentRadius = camera.radius - const targetRadius = Math.max(camera.lowerRadiusLimit ?? 0.1, currentRadius * 0.8) - - Animation.CreateAndStartAnimation( - 'zoomIn', - camera, - 'radius', - frameRate, - totalFrames, - currentRadius, - targetRadius, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - } - } - const handleZoomOut = () => { - const camera = sceneRef.current?.activeCamera as ArcRotateCamera - if (camera) { - sceneRef.current?.stopAnimation(camera) - const ease = new CubicEase() - ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT) - - const frameRate = 60 - const durationMs = 300 - const totalFrames = Math.round((durationMs / 1000) * frameRate) - - const currentRadius = camera.radius - const targetRadius = Math.min(camera.upperRadiusLimit ?? Infinity, currentRadius * 1.2) - - Animation.CreateAndStartAnimation( - 'zoomOut', - camera, - 'radius', - frameRate, - totalFrames, - currentRadius, - targetRadius, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - } - } - const handleTopView = () => { - const camera = sceneRef.current?.activeCamera as ArcRotateCamera; - if (camera) { - sceneRef.current?.stopAnimation(camera); - const ease = new CubicEase(); - ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT); - - const frameRate = 60; - const durationMs = 500; - const totalFrames = Math.round((durationMs / 1000) * frameRate); - - Animation.CreateAndStartAnimation( - 'topViewAlpha', - camera, - 'alpha', - frameRate, - totalFrames, - camera.alpha, - Math.PI / 2, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ); - - Animation.CreateAndStartAnimation( - 'topViewBeta', - camera, - 'beta', - frameRate, - totalFrames, - camera.beta, - 0, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ); - } - }; - - // NEW: Function to handle overlay circle click - const handleOverlayCircleClick = (sensorId: string) => { - console.log('[ModelViewer] Overlay circle clicked:', sensorId) - - // Find the mesh for this sensor - const allMeshes = importedMeshesRef.current || [] - const sensorMeshes = collectSensorMeshes(allMeshes) - const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId) - - if (!targetMesh) { - console.warn(`[ModelViewer] Mesh not found for sensor: ${sensorId}`) - return - } - - const scene = sceneRef.current - const camera = scene?.activeCamera as ArcRotateCamera - if (!scene || !camera) return - - // Calculate bounding box of the sensor mesh - const bbox = (typeof targetMesh.getHierarchyBoundingVectors === 'function') - ? targetMesh.getHierarchyBoundingVectors() - : { - min: targetMesh.getBoundingInfo().boundingBox.minimumWorld, - max: targetMesh.getBoundingInfo().boundingBox.maximumWorld - } - - const center = bbox.min.add(bbox.max).scale(0.5) - const size = bbox.max.subtract(bbox.min) - const maxDimension = Math.max(size.x, size.y, size.z) - - // Calculate optimal camera distance - const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) - - // Stop any current animations - scene.stopAnimation(camera) - - // Setup easing - const ease = new CubicEase() - ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) - - const frameRate = 60 - const durationMs = 600 // 0.6 seconds for smooth animation - const totalFrames = Math.round((durationMs / 1000) * frameRate) - - // Animate camera target position - Animation.CreateAndStartAnimation( - 'camTarget', - camera, - 'target', - frameRate, - totalFrames, - camera.target.clone(), - center.clone(), - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - - // Animate camera radius (zoom) - Animation.CreateAndStartAnimation( - 'camRadius', - camera, - 'radius', - frameRate, - totalFrames, - camera.radius, - targetRadius, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - - // Call callback to display tooltip - onSensorPick?.(sensorId) - - console.log('[ModelViewer] Camera animation started for sensor:', sensorId) - } - - useEffect(() => { - isDisposedRef.current = false - isInitializedRef.current = false - return () => { - isDisposedRef.current = true - } - }, []) - - useEffect(() => { - if (!canvasRef.current || isInitializedRef.current) return - - const canvas = canvasRef.current - setWebglError(null) - - let hasWebGL = false - try { - const testCanvas = document.createElement('canvas') - const gl = - testCanvas.getContext('webgl2') || - testCanvas.getContext('webgl') || - testCanvas.getContext('experimental-webgl') - hasWebGL = !!gl - } catch { - hasWebGL = false - } - - if (!hasWebGL) { - const message = 'WebGL не поддерживается в текущем окружении' - setWebglError(message) - onError?.(message) - setIsLoading(false) - setModelReady(false) - return - } - - let engine: Engine - try { - // Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU - engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const message = `WebGL недоступен: ${errorMessage}` - setWebglError(message) - onError?.(message) - setIsLoading(false) - setModelReady(false) - return - } - engineRef.current = engine - - engine.runRenderLoop(() => { - if (!isDisposedRef.current && sceneRef.current) { - sceneRef.current.render() - } - }) - - const scene = new Scene(engine) - sceneRef.current = scene - - scene.clearColor = new Color4(0.1, 0.1, 0.15, 1) - - // Оптимизация: включаем FXAA (более легковесное сглаживание) - scene.imageProcessingConfiguration.fxaaEnabled = true - - const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene) - camera.attachControl(canvas, true) - camera.lowerRadiusLimit = 2 - camera.upperRadiusLimit = 200 - camera.wheelDeltaPercentage = 0.01 - camera.panningSensibility = 50 - camera.angularSensibilityX = 1000 - camera.angularSensibilityY = 1000 - - const ambientLight = new HemisphericLight('ambientLight', new Vector3(0, 1, 0), scene) - ambientLight.intensity = 0.4 - ambientLight.diffuse = new Color3(0.7, 0.7, 0.8) - ambientLight.specular = new Color3(0.2, 0.2, 0.3) - ambientLight.groundColor = new Color3(0.3, 0.3, 0.4) - - const keyLight = new HemisphericLight('keyLight', new Vector3(1, 1, 0), scene) - keyLight.intensity = 0.6 - keyLight.diffuse = new Color3(1, 1, 0.9) - keyLight.specular = new Color3(1, 1, 0.9) - - const fillLight = new HemisphericLight('fillLight', new Vector3(-1, 0.5, -1), scene) - fillLight.intensity = 0.3 - fillLight.diffuse = new Color3(0.8, 0.8, 1) - - const hl = new HighlightLayer('highlight-layer', scene, { - mainTextureRatio: 1, - blurTextureSizeRatio: 1, - }) - hl.innerGlow = false - hl.outerGlow = true - hl.blurHorizontalSize = 2 - hl.blurVerticalSize = 2 - highlightLayerRef.current = hl - - const handleResize = () => { - if (!isDisposedRef.current) { - engine.resize() - } - } - window.addEventListener('resize', handleResize) - - isInitializedRef.current = true - - return () => { - isDisposedRef.current = true - isInitializedRef.current = false - window.removeEventListener('resize', handleResize) - - highlightLayerRef.current?.dispose() - highlightLayerRef.current = null - if (engineRef.current) { - engineRef.current.dispose() - engineRef.current = null - } - sceneRef.current = null - } - }, [onError]) - - useEffect(() => { - if (!modelPath || !sceneRef.current || !engineRef.current) return - - const scene = sceneRef.current - - setIsLoading(true) - setLoadingProgress(0) - setShowModel(false) - setModelReady(false) - - const loadModel = async () => { - try { - console.log('[ModelViewer] Starting to load model:', modelPath) - - // UI элемент загрузчика (есть эффект замедленности) - const progressInterval = setInterval(() => { - setLoadingProgress(prev => { - if (prev >= 90) { - clearInterval(progressInterval) - return 90 - } - return prev + Math.random() * 15 - }) - }, 100) - - // Use the correct ImportMeshAsync signature: (url, scene, onProgress) - const result = await ImportMeshAsync(modelPath, scene, (evt) => { - if (evt.lengthComputable) { - const progress = (evt.loaded / evt.total) * 100 - setLoadingProgress(progress) - console.log('[ModelViewer] Loading progress:', progress) - } - }) - - clearInterval(progressInterval) - - if (isDisposedRef.current) { - console.log('[ModelViewer] Component disposed during load') - return - } - - console.log('[ModelViewer] Model loaded successfully:', { - meshesCount: result.meshes.length, - particleSystemsCount: result.particleSystems.length, - skeletonsCount: result.skeletons.length, - animationGroupsCount: result.animationGroups.length - }) - - importedMeshesRef.current = result.meshes - - if (result.meshes.length > 0) { - const boundingBox = result.meshes[0].getHierarchyBoundingVectors() - - onModelLoaded?.({ - meshes: result.meshes, - boundingBox: { - min: { x: boundingBox.min.x, y: boundingBox.min.y, z: boundingBox.min.z }, - max: { x: boundingBox.max.x, y: boundingBox.max.y, z: boundingBox.max.z }, - }, - }) - - // Автоматическое кадрирование камеры для отображения всей модели - const camera = scene.activeCamera as ArcRotateCamera - if (camera) { - const center = boundingBox.min.add(boundingBox.max).scale(0.5) - const size = boundingBox.max.subtract(boundingBox.min) - const maxDimension = Math.max(size.x, size.y, size.z) - - // Устанавливаем оптимальное расстояние камеры - const targetRadius = maxDimension * 2.5 // Множитель для комфортного отступа - - // Плавная анимация камеры к центру модели - scene.stopAnimation(camera) - - const ease = new CubicEase() - ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) - - const frameRate = 60 - const durationMs = 800 // 0.8 секунды - const totalFrames = Math.round((durationMs / 1000) * frameRate) - - // Анимация позиции камеры - Animation.CreateAndStartAnimation( - 'frameCameraTarget', - camera, - 'target', - frameRate, - totalFrames, - camera.target.clone(), - center.clone(), - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - - // Анимация зума - Animation.CreateAndStartAnimation( - 'frameCameraRadius', - camera, - 'radius', - frameRate, - totalFrames, - camera.radius, - targetRadius, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - - console.log('[ModelViewer] Camera framed to model:', { center, targetRadius, maxDimension }) - } - } - - setLoadingProgress(100) - setShowModel(true) - setModelReady(true) - setIsLoading(false) - } catch (error) { - if (isDisposedRef.current) return - const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка' - console.error('[ModelViewer] Error loading model:', errorMessage) - const message = `Ошибка при загрузке модели: ${errorMessage}` - onError?.(message) - setIsLoading(false) - setModelReady(false) - } - } - - loadModel() - }, [modelPath, onModelLoaded, onError]) - - useEffect(() => { - if (!highlightAllSensors || focusSensorId || !modelReady) { - setAllSensorsOverlayCircles([]) - return - } - - const scene = sceneRef.current - const engine = engineRef.current - if (!scene || !engine) { - setAllSensorsOverlayCircles([]) - return - } - - const allMeshes = importedMeshesRef.current || [] - const sensorMeshes = collectSensorMeshes(allMeshes) - if (sensorMeshes.length === 0) { - setAllSensorsOverlayCircles([]) - return - } - - const engineTyped = engine as Engine - const updateCircles = () => { - const circles = computeSensorOverlayCircles({ - scene, - engine: engineTyped, - meshes: sensorMeshes, - sensorStatusMap, - }) - setAllSensorsOverlayCircles(circles) - } - - updateCircles() - const observer = scene.onBeforeRenderObservable.add(updateCircles) - return () => { - scene.onBeforeRenderObservable.remove(observer) - setAllSensorsOverlayCircles([]) - } - }, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap]) - - useEffect(() => { - if (!highlightAllSensors || focusSensorId || !modelReady) { - return - } - - const scene = sceneRef.current - if (!scene) return - - const allMeshes = importedMeshesRef.current || [] - if (allMeshes.length === 0) { - return - } - - const sensorMeshes = collectSensorMeshes(allMeshes) - - console.log('[ModelViewer] Total meshes in model:', allMeshes.length) - console.log('[ModelViewer] Sensor meshes found:', sensorMeshes.length) - - // Log first 5 sensor IDs found in meshes - const sensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean).slice(0, 5) - console.log('[ModelViewer] Sample sensor IDs from meshes:', sensorIds) - - if (sensorMeshes.length === 0) { - console.warn('[ModelViewer] No sensor meshes found in 3D model!') - return - } - - applyHighlightToMeshes( - highlightLayerRef.current, - highlightedMeshesRef, - sensorMeshes, - mesh => { - const sid = getSensorIdFromMesh(mesh) - const status = sid ? sensorStatusMap?.[sid] : undefined - return statusToColor3(status ?? null) - }, - ) - }, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap]) - - useEffect(() => { - if (!focusSensorId || !modelReady) { - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - highlightLayerRef.current?.removeAllMeshes() - chosenMeshRef.current = null - setOverlayPos(null) - setOverlayData(null) - setAllSensorsOverlayCircles([]) - return - } - - const sensorId = (focusSensorId ?? '').trim() - if (!sensorId) { - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - highlightLayerRef.current?.removeAllMeshes() - chosenMeshRef.current = null - setOverlayPos(null) - setOverlayData(null) - return - } - - const allMeshes = importedMeshesRef.current || [] - - if (allMeshes.length === 0) { - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - highlightLayerRef.current?.removeAllMeshes() - chosenMeshRef.current = null - setOverlayPos(null) - setOverlayData(null) - return - } - - const sensorMeshes = collectSensorMeshes(allMeshes) - const allSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)) - const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId) - - console.log('[ModelViewer] Sensor focus', { - requested: sensorId, - totalImportedMeshes: allMeshes.length, - totalSensorMeshes: sensorMeshes.length, - allSensorIds: allSensorIds, - chosen: chosen ? { id: chosen.id, name: chosen.name, uniqueId: chosen.uniqueId, parent: chosen.parent?.name } : null, - source: 'result.meshes', - }) - - const scene = sceneRef.current! - - if (chosen) { - try { - const camera = scene.activeCamera as ArcRotateCamera - const bbox = (typeof chosen.getHierarchyBoundingVectors === 'function') - ? chosen.getHierarchyBoundingVectors() - : { min: chosen.getBoundingInfo().boundingBox.minimumWorld, max: chosen.getBoundingInfo().boundingBox.maximumWorld } - const center = bbox.min.add(bbox.max).scale(0.5) - const size = bbox.max.subtract(bbox.min) - const maxDimension = Math.max(size.x, size.y, size.z) - const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) - - // Простое позиционирование камеры - всегда поворачиваемся к датчику - console.log('[ModelViewer] Calculating camera direction to sensor') - - // Вычисляем направление от текущей позиции камеры к датчику - const directionToSensor = center.subtract(camera.position).normalize() - - // Преобразуем в сферические координаты - // alpha - горизонтальный угол (вокруг оси Y) - let targetAlpha = Math.atan2(directionToSensor.x, directionToSensor.z) - - // beta - вертикальный угол (от вертикали) - // Используем оптимальный угол 60° для обзора - let targetBeta = Math.PI / 3 // 60° - - console.log('[ModelViewer] Calculated camera direction:', { - alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°', - beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°', - sensorPosition: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) }, - cameraPosition: { x: camera.position.x.toFixed(2), y: camera.position.y.toFixed(2), z: camera.position.z.toFixed(2) } - }) - - // Нормализуем alpha в диапазон [-PI, PI] - while (targetAlpha > Math.PI) targetAlpha -= 2 * Math.PI - while (targetAlpha < -Math.PI) targetAlpha += 2 * Math.PI - - // Ограничиваем beta в разумных пределах - targetBeta = Math.max(0.1, Math.min(Math.PI - 0.1, targetBeta)) - - scene.stopAnimation(camera) - - // Логирование перед анимацией - console.log('[ModelViewer] Starting camera animation:', { - sensorId, - from: { - target: { x: camera.target.x.toFixed(2), y: camera.target.y.toFixed(2), z: camera.target.z.toFixed(2) }, - radius: camera.radius.toFixed(2), - alpha: (camera.alpha * 180 / Math.PI).toFixed(1) + '°', - beta: (camera.beta * 180 / Math.PI).toFixed(1) + '°' - }, - to: { - target: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) }, - radius: targetRadius.toFixed(2), - alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°', - beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°' - }, - alphaChange: ((targetAlpha - camera.alpha) * 180 / Math.PI).toFixed(1) + '°', - betaChange: ((targetBeta - camera.beta) * 180 / Math.PI).toFixed(1) + '°' - }) - - const ease = new CubicEase() - ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) - const frameRate = 60 - const durationMs = 800 - const totalFrames = Math.round((durationMs / 1000) * frameRate) - - Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease) - Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) - Animation.CreateAndStartAnimation('camAlpha', camera, 'alpha', frameRate, totalFrames, camera.alpha, targetAlpha, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) - Animation.CreateAndStartAnimation('camBeta', camera, 'beta', frameRate, totalFrames, camera.beta, targetBeta, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) - - applyHighlightToMeshes( - highlightLayerRef.current, - highlightedMeshesRef, - [chosen], - mesh => { - const sid = getSensorIdFromMesh(mesh) - const status = sid ? sensorStatusMap?.[sid] : undefined - return statusToColor3(status ?? null) - }, - ) - chosenMeshRef.current = chosen - setOverlayData({ name: chosen.name, sensorId }) - } catch { - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - highlightLayerRef.current?.removeAllMeshes() - chosenMeshRef.current = null - setOverlayPos(null) - setOverlayData(null) - } - } else { - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - highlightLayerRef.current?.removeAllMeshes() - chosenMeshRef.current = null - setOverlayPos(null) - setOverlayData(null) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [focusSensorId, modelReady, highlightAllSensors]) - - useEffect(() => { - const scene = sceneRef.current - if (!scene || !modelReady || !isSensorSelectionEnabled) return - - const pickObserver = scene.onPointerObservable.add((pointerInfo: PointerInfo) => { - if (pointerInfo.type !== PointerEventTypes.POINTERPICK) return - const pick = pointerInfo.pickInfo - if (!pick || !pick.hit) { - onSensorPick?.(null) - return - } - - const pickedMesh = pick.pickedMesh - const sensorId = getSensorIdFromMesh(pickedMesh) - - if (sensorId) { - onSensorPick?.(sensorId) - } else { - onSensorPick?.(null) - } - }) - - return () => { - scene.onPointerObservable.remove(pickObserver) - } - }, [modelReady, isSensorSelectionEnabled, onSensorPick]) - - const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => { - if (!sceneRef.current || !mesh) return null - const scene = sceneRef.current - try { - const bbox = (typeof mesh.getHierarchyBoundingVectors === 'function') - ? mesh.getHierarchyBoundingVectors() - : { min: mesh.getBoundingInfo().boundingBox.minimumWorld, max: mesh.getBoundingInfo().boundingBox.maximumWorld } - const center = bbox.min.add(bbox.max).scale(0.5) - - const viewport = scene.activeCamera?.viewport.toGlobal(engineRef.current!.getRenderWidth(), engineRef.current!.getRenderHeight()) - if (!viewport) return null - - const projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport) - if (!projected) return null - - return { left: projected.x, top: projected.y } - } catch (error) { - console.error('[ModelViewer] Error computing overlay position:', error) - return null - } - }, []) - - useEffect(() => { - if (!chosenMeshRef.current || !overlayData) return - const pos = computeOverlayPosition(chosenMeshRef.current) - setOverlayPos(pos) - }, [overlayData, computeOverlayPosition]) - - useEffect(() => { - if (!sceneRef.current || !chosenMeshRef.current || !overlayData) return - const scene = sceneRef.current - - const updateOverlayPosition = () => { - const pos = computeOverlayPosition(chosenMeshRef.current) - setOverlayPos(pos) - } - scene.registerBeforeRender(updateOverlayPosition) - return () => scene.unregisterBeforeRender(updateOverlayPosition) - }, [overlayData, computeOverlayPosition]) - - return ( -
- {!modelPath ? ( -
-
-
- 3D модель не выбрана -
-
- Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр -
-
- Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API -
-
-
- ) : ( - <> - - {webglError ? ( -
-
-
- 3D просмотр недоступен -
-
- {webglError} -
-
- Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве -
-
-
- ) : isLoading ? ( -
- -
- ) : !modelReady ? ( -
-
-
- 3D модель не загружена -
-
- Модель не готова к отображению -
-
-
- ) : null} - - - - )} - {/* UPDATED: Interactive overlay circles with hover effects */} - {allSensorsOverlayCircles.map(circle => { - const size = 36 - const radius = size / 2 - const fill = hexWithAlpha(circle.colorHex, 0.2) - const isHovered = hoveredSensorId === circle.sensorId - - return ( -
handleOverlayCircleClick(circle.sensorId)} - onMouseEnter={() => setHoveredSensorId(circle.sensorId)} - onMouseLeave={() => setHoveredSensorId(null)} - style={{ - position: 'absolute', - left: circle.left - radius, - top: circle.top - radius, - width: size, - height: size, - borderRadius: '9999px', - border: `2px solid ${circle.colorHex}`, - backgroundColor: fill, - pointerEvents: 'auto', - cursor: 'pointer', - transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)', - transform: isHovered ? 'scale(1.4)' : 'scale(1)', - boxShadow: isHovered - ? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}` - : `0 0 8px ${circle.colorHex}`, - zIndex: isHovered ? 50 : 10, - }} - title={`Датчик: ${circle.sensorId}`} - /> - ) - })} - {renderOverlay && overlayPos && overlayData - ? renderOverlay({ anchor: overlayPos, info: overlayData }) - : null - } -
- ) -} - -export default ModelViewer diff --git a/frontend/components/model/ModelViewer.tsx — копия 4 b/frontend/components/model/ModelViewer.tsx — копия 4 deleted file mode 100644 index 943f55b..0000000 --- a/frontend/components/model/ModelViewer.tsx — копия 4 +++ /dev/null @@ -1,971 +0,0 @@ -'use client' - -import React, { useEffect, useRef, useState } from 'react' - -import { - Engine, - Scene, - Vector3, - HemisphericLight, - ArcRotateCamera, - Color3, - Color4, - AbstractMesh, - Nullable, - HighlightLayer, - Animation, - CubicEase, - EasingFunction, - ImportMeshAsync, - PointerEventTypes, - PointerInfo, - Matrix, - Ray, -} from '@babylonjs/core' -import '@babylonjs/loaders' - -import SceneToolbar from './SceneToolbar'; -import LoadingSpinner from '../ui/LoadingSpinner' -import useNavigationStore from '@/app/store/navigationStore' -import { - getSensorIdFromMesh, - collectSensorMeshes, - applyHighlightToMeshes, - statusToColor3, -} from './sensorHighlight' -import { - computeSensorOverlayCircles, - hexWithAlpha, -} from './sensorHighlightOverlay' - -export interface ModelViewerProps { - modelPath: string - onSelectModel: (path: string) => void; - onModelLoaded?: (modelData: { - meshes: AbstractMesh[] - boundingBox: { - min: { x: number; y: number; z: number } - max: { x: number; y: number; z: number } - } - }) => void - onError?: (error: string) => void - activeMenu?: string | null - focusSensorId?: string | null - renderOverlay?: (params: { anchor: { left: number; top: number } | null; info?: { name?: string; sensorId?: string } | null }) => React.ReactNode - isSensorSelectionEnabled?: boolean - onSensorPick?: (sensorId: string | null) => void - highlightAllSensors?: boolean - sensorStatusMap?: Record -} - -const ModelViewer: React.FC = ({ - modelPath, - onSelectModel, - onModelLoaded, - onError, - focusSensorId, - renderOverlay, - isSensorSelectionEnabled, - onSensorPick, - highlightAllSensors, - sensorStatusMap, - showStats = false, - onToggleStats, -}) => { - const canvasRef = useRef(null) - const engineRef = useRef>(null) - const sceneRef = useRef>(null) - const [isLoading, setIsLoading] = useState(false) - const [loadingProgress, setLoadingProgress] = useState(0) - const [showModel, setShowModel] = useState(false) - const isInitializedRef = useRef(false) - const isDisposedRef = useRef(false) - const importedMeshesRef = useRef([]) - const highlightLayerRef = useRef(null) - const highlightedMeshesRef = useRef([]) - const chosenMeshRef = useRef(null) - const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null) - const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null) - const [modelReady, setModelReady] = useState(false) - const [panActive, setPanActive] = useState(false); - const [webglError, setWebglError] = useState(null) - const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState< - { sensorId: string; left: number; top: number; colorHex: string }[] - >([]) - // NEW: State for tracking hovered sensor in overlay circles - const [hoveredSensorId, setHoveredSensorId] = useState(null) - - const handlePan = () => setPanActive(!panActive); - - useEffect(() => { - const scene = sceneRef.current; - const camera = scene?.activeCamera as ArcRotateCamera; - const canvas = canvasRef.current; - - if (!scene || !camera || !canvas) { - return; - } - - let observer: any = null; - - if (panActive) { - camera.detachControl(); - - observer = scene.onPointerObservable.add((pointerInfo: PointerInfo) => { - const evt = pointerInfo.event; - - if (evt.buttons === 1) { - camera.inertialPanningX -= evt.movementX / camera.panningSensibility; - camera.inertialPanningY += evt.movementY / camera.panningSensibility; - } - else if (evt.buttons === 2) { - camera.inertialAlphaOffset -= evt.movementX / camera.angularSensibilityX; - camera.inertialBetaOffset -= evt.movementY / camera.angularSensibilityY; - } - }, PointerEventTypes.POINTERMOVE); - - } else { - camera.detachControl(); - camera.attachControl(canvas, true); - } - - return () => { - if (observer) { - scene.onPointerObservable.remove(observer); - } - if (!camera.isDisposed() && !camera.inputs.attachedToElement) { - camera.attachControl(canvas, true); - } - }; - }, [panActive, sceneRef, canvasRef]); - - const handleZoomIn = () => { - const camera = sceneRef.current?.activeCamera as ArcRotateCamera - if (camera) { - sceneRef.current?.stopAnimation(camera) - const ease = new CubicEase() - ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT) - - const frameRate = 60 - const durationMs = 300 - const totalFrames = Math.round((durationMs / 1000) * frameRate) - - const currentRadius = camera.radius - const targetRadius = Math.max(camera.lowerRadiusLimit ?? 0.1, currentRadius * 0.8) - - Animation.CreateAndStartAnimation( - 'zoomIn', - camera, - 'radius', - frameRate, - totalFrames, - currentRadius, - targetRadius, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - } - } - const handleZoomOut = () => { - const camera = sceneRef.current?.activeCamera as ArcRotateCamera - if (camera) { - sceneRef.current?.stopAnimation(camera) - const ease = new CubicEase() - ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT) - - const frameRate = 60 - const durationMs = 300 - const totalFrames = Math.round((durationMs / 1000) * frameRate) - - const currentRadius = camera.radius - const targetRadius = Math.min(camera.upperRadiusLimit ?? Infinity, currentRadius * 1.2) - - Animation.CreateAndStartAnimation( - 'zoomOut', - camera, - 'radius', - frameRate, - totalFrames, - currentRadius, - targetRadius, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - } - } - const handleTopView = () => { - const camera = sceneRef.current?.activeCamera as ArcRotateCamera; - if (camera) { - sceneRef.current?.stopAnimation(camera); - const ease = new CubicEase(); - ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT); - - const frameRate = 60; - const durationMs = 500; - const totalFrames = Math.round((durationMs / 1000) * frameRate); - - Animation.CreateAndStartAnimation( - 'topViewAlpha', - camera, - 'alpha', - frameRate, - totalFrames, - camera.alpha, - Math.PI / 2, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ); - - Animation.CreateAndStartAnimation( - 'topViewBeta', - camera, - 'beta', - frameRate, - totalFrames, - camera.beta, - 0, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ); - } - }; - - // NEW: Function to handle overlay circle click - const handleOverlayCircleClick = (sensorId: string) => { - console.log('[ModelViewer] Overlay circle clicked:', sensorId) - - // Find the mesh for this sensor - const allMeshes = importedMeshesRef.current || [] - const sensorMeshes = collectSensorMeshes(allMeshes) - const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId) - - if (!targetMesh) { - console.warn(`[ModelViewer] Mesh not found for sensor: ${sensorId}`) - return - } - - const scene = sceneRef.current - const camera = scene?.activeCamera as ArcRotateCamera - if (!scene || !camera) return - - // Calculate bounding box of the sensor mesh - const bbox = (typeof targetMesh.getHierarchyBoundingVectors === 'function') - ? targetMesh.getHierarchyBoundingVectors() - : { - min: targetMesh.getBoundingInfo().boundingBox.minimumWorld, - max: targetMesh.getBoundingInfo().boundingBox.maximumWorld - } - - const center = bbox.min.add(bbox.max).scale(0.5) - const size = bbox.max.subtract(bbox.min) - const maxDimension = Math.max(size.x, size.y, size.z) - - // Calculate optimal camera distance - const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) - - // Stop any current animations - scene.stopAnimation(camera) - - // Setup easing - const ease = new CubicEase() - ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) - - const frameRate = 60 - const durationMs = 600 // 0.6 seconds for smooth animation - const totalFrames = Math.round((durationMs / 1000) * frameRate) - - // Animate camera target position - Animation.CreateAndStartAnimation( - 'camTarget', - camera, - 'target', - frameRate, - totalFrames, - camera.target.clone(), - center.clone(), - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - - // Animate camera radius (zoom) - Animation.CreateAndStartAnimation( - 'camRadius', - camera, - 'radius', - frameRate, - totalFrames, - camera.radius, - targetRadius, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - - // Call callback to display tooltip - onSensorPick?.(sensorId) - - console.log('[ModelViewer] Camera animation started for sensor:', sensorId) - } - - useEffect(() => { - isDisposedRef.current = false - isInitializedRef.current = false - return () => { - isDisposedRef.current = true - } - }, []) - - useEffect(() => { - if (!canvasRef.current || isInitializedRef.current) return - - const canvas = canvasRef.current - setWebglError(null) - - let hasWebGL = false - try { - const testCanvas = document.createElement('canvas') - const gl = - testCanvas.getContext('webgl2') || - testCanvas.getContext('webgl') || - testCanvas.getContext('experimental-webgl') - hasWebGL = !!gl - } catch { - hasWebGL = false - } - - if (!hasWebGL) { - const message = 'WebGL не поддерживается в текущем окружении' - setWebglError(message) - onError?.(message) - setIsLoading(false) - setModelReady(false) - return - } - - let engine: Engine - try { - // Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU - engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const message = `WebGL недоступен: ${errorMessage}` - setWebglError(message) - onError?.(message) - setIsLoading(false) - setModelReady(false) - return - } - engineRef.current = engine - - engine.runRenderLoop(() => { - if (!isDisposedRef.current && sceneRef.current) { - sceneRef.current.render() - } - }) - - const scene = new Scene(engine) - sceneRef.current = scene - - scene.clearColor = new Color4(0.1, 0.1, 0.15, 1) - - // Оптимизация: включаем FXAA (более легковесное сглаживание) - scene.imageProcessingConfiguration.fxaaEnabled = true - - const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene) - camera.attachControl(canvas, true) - camera.lowerRadiusLimit = 2 - camera.upperRadiusLimit = 200 - camera.wheelDeltaPercentage = 0.01 - camera.panningSensibility = 50 - camera.angularSensibilityX = 1000 - camera.angularSensibilityY = 1000 - - const ambientLight = new HemisphericLight('ambientLight', new Vector3(0, 1, 0), scene) - ambientLight.intensity = 0.4 - ambientLight.diffuse = new Color3(0.7, 0.7, 0.8) - ambientLight.specular = new Color3(0.2, 0.2, 0.3) - ambientLight.groundColor = new Color3(0.3, 0.3, 0.4) - - const keyLight = new HemisphericLight('keyLight', new Vector3(1, 1, 0), scene) - keyLight.intensity = 0.6 - keyLight.diffuse = new Color3(1, 1, 0.9) - keyLight.specular = new Color3(1, 1, 0.9) - - const fillLight = new HemisphericLight('fillLight', new Vector3(-1, 0.5, -1), scene) - fillLight.intensity = 0.3 - fillLight.diffuse = new Color3(0.8, 0.8, 1) - - const hl = new HighlightLayer('highlight-layer', scene, { - mainTextureRatio: 1, - blurTextureSizeRatio: 1, - }) - hl.innerGlow = false - hl.outerGlow = true - hl.blurHorizontalSize = 2 - hl.blurVerticalSize = 2 - highlightLayerRef.current = hl - - const handleResize = () => { - if (!isDisposedRef.current) { - engine.resize() - } - } - window.addEventListener('resize', handleResize) - - isInitializedRef.current = true - - return () => { - isDisposedRef.current = true - isInitializedRef.current = false - window.removeEventListener('resize', handleResize) - - highlightLayerRef.current?.dispose() - highlightLayerRef.current = null - if (engineRef.current) { - engineRef.current.dispose() - engineRef.current = null - } - sceneRef.current = null - } - }, [onError]) - - useEffect(() => { - if (!modelPath || !sceneRef.current || !engineRef.current) return - - const scene = sceneRef.current - - setIsLoading(true) - setLoadingProgress(0) - setShowModel(false) - setModelReady(false) - - const loadModel = async () => { - try { - console.log('[ModelViewer] Starting to load model:', modelPath) - - // UI элемент загрузчика (есть эффект замедленности) - const progressInterval = setInterval(() => { - setLoadingProgress(prev => { - if (prev >= 90) { - clearInterval(progressInterval) - return 90 - } - return prev + Math.random() * 15 - }) - }, 100) - - // Use the correct ImportMeshAsync signature: (url, scene, onProgress) - const result = await ImportMeshAsync(modelPath, scene, (evt) => { - if (evt.lengthComputable) { - const progress = (evt.loaded / evt.total) * 100 - setLoadingProgress(progress) - console.log('[ModelViewer] Loading progress:', progress) - } - }) - - clearInterval(progressInterval) - - if (isDisposedRef.current) { - console.log('[ModelViewer] Component disposed during load') - return - } - - console.log('[ModelViewer] Model loaded successfully:', { - meshesCount: result.meshes.length, - particleSystemsCount: result.particleSystems.length, - skeletonsCount: result.skeletons.length, - animationGroupsCount: result.animationGroups.length - }) - - importedMeshesRef.current = result.meshes - - if (result.meshes.length > 0) { - const boundingBox = result.meshes[0].getHierarchyBoundingVectors() - - onModelLoaded?.({ - meshes: result.meshes, - boundingBox: { - min: { x: boundingBox.min.x, y: boundingBox.min.y, z: boundingBox.min.z }, - max: { x: boundingBox.max.x, y: boundingBox.max.y, z: boundingBox.max.z }, - }, - }) - - // Автоматическое кадрирование камеры для отображения всей модели - const camera = scene.activeCamera as ArcRotateCamera - if (camera) { - const center = boundingBox.min.add(boundingBox.max).scale(0.5) - const size = boundingBox.max.subtract(boundingBox.min) - const maxDimension = Math.max(size.x, size.y, size.z) - - // Устанавливаем оптимальное расстояние камеры - const targetRadius = maxDimension * 2.5 // Множитель для комфортного отступа - - // Плавная анимация камеры к центру модели - scene.stopAnimation(camera) - - const ease = new CubicEase() - ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) - - const frameRate = 60 - const durationMs = 800 // 0.8 секунды - const totalFrames = Math.round((durationMs / 1000) * frameRate) - - // Анимация позиции камеры - Animation.CreateAndStartAnimation( - 'frameCameraTarget', - camera, - 'target', - frameRate, - totalFrames, - camera.target.clone(), - center.clone(), - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - - // Анимация зума - Animation.CreateAndStartAnimation( - 'frameCameraRadius', - camera, - 'radius', - frameRate, - totalFrames, - camera.radius, - targetRadius, - Animation.ANIMATIONLOOPMODE_CONSTANT, - ease - ) - - console.log('[ModelViewer] Camera framed to model:', { center, targetRadius, maxDimension }) - } - } - - setLoadingProgress(100) - setShowModel(true) - setModelReady(true) - setIsLoading(false) - } catch (error) { - if (isDisposedRef.current) return - const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка' - console.error('[ModelViewer] Error loading model:', errorMessage) - const message = `Ошибка при загрузке модели: ${errorMessage}` - onError?.(message) - setIsLoading(false) - setModelReady(false) - } - } - - loadModel() - }, [modelPath, onModelLoaded, onError]) - - useEffect(() => { - if (!highlightAllSensors || focusSensorId || !modelReady) { - setAllSensorsOverlayCircles([]) - return - } - - const scene = sceneRef.current - const engine = engineRef.current - if (!scene || !engine) { - setAllSensorsOverlayCircles([]) - return - } - - const allMeshes = importedMeshesRef.current || [] - const sensorMeshes = collectSensorMeshes(allMeshes) - if (sensorMeshes.length === 0) { - setAllSensorsOverlayCircles([]) - return - } - - const engineTyped = engine as Engine - const updateCircles = () => { - const circles = computeSensorOverlayCircles({ - scene, - engine: engineTyped, - meshes: sensorMeshes, - sensorStatusMap, - }) - setAllSensorsOverlayCircles(circles) - } - - updateCircles() - const observer = scene.onBeforeRenderObservable.add(updateCircles) - return () => { - scene.onBeforeRenderObservable.remove(observer) - setAllSensorsOverlayCircles([]) - } - }, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap]) - - useEffect(() => { - if (!highlightAllSensors || focusSensorId || !modelReady) { - return - } - - const scene = sceneRef.current - if (!scene) return - - const allMeshes = importedMeshesRef.current || [] - if (allMeshes.length === 0) { - return - } - - const sensorMeshes = collectSensorMeshes(allMeshes) - - console.log('[ModelViewer] Total meshes in model:', allMeshes.length) - console.log('[ModelViewer] Sensor meshes found:', sensorMeshes.length) - - // Log first 5 sensor IDs found in meshes - const sensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean).slice(0, 5) - console.log('[ModelViewer] Sample sensor IDs from meshes:', sensorIds) - - if (sensorMeshes.length === 0) { - console.warn('[ModelViewer] No sensor meshes found in 3D model!') - return - } - - applyHighlightToMeshes( - highlightLayerRef.current, - highlightedMeshesRef, - sensorMeshes, - mesh => { - const sid = getSensorIdFromMesh(mesh) - const status = sid ? sensorStatusMap?.[sid] : undefined - return statusToColor3(status ?? null) - }, - ) - }, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap]) - - useEffect(() => { - if (!focusSensorId || !modelReady) { - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - highlightLayerRef.current?.removeAllMeshes() - chosenMeshRef.current = null - setOverlayPos(null) - setOverlayData(null) - setAllSensorsOverlayCircles([]) - return - } - - const sensorId = (focusSensorId ?? '').trim() - if (!sensorId) { - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - highlightLayerRef.current?.removeAllMeshes() - chosenMeshRef.current = null - setOverlayPos(null) - setOverlayData(null) - return - } - - const allMeshes = importedMeshesRef.current || [] - - if (allMeshes.length === 0) { - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - highlightLayerRef.current?.removeAllMeshes() - chosenMeshRef.current = null - setOverlayPos(null) - setOverlayData(null) - return - } - - const sensorMeshes = collectSensorMeshes(allMeshes) - const allSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)) - const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId) - - console.log('[ModelViewer] Sensor focus', { - requested: sensorId, - totalImportedMeshes: allMeshes.length, - totalSensorMeshes: sensorMeshes.length, - allSensorIds: allSensorIds, - chosen: chosen ? { id: chosen.id, name: chosen.name, uniqueId: chosen.uniqueId, parent: chosen.parent?.name } : null, - source: 'result.meshes', - }) - - const scene = sceneRef.current! - - if (chosen) { - try { - const camera = scene.activeCamera as ArcRotateCamera - const bbox = (typeof chosen.getHierarchyBoundingVectors === 'function') - ? chosen.getHierarchyBoundingVectors() - : { min: chosen.getBoundingInfo().boundingBox.minimumWorld, max: chosen.getBoundingInfo().boundingBox.maximumWorld } - const center = bbox.min.add(bbox.max).scale(0.5) - const size = bbox.max.subtract(bbox.min) - const maxDimension = Math.max(size.x, size.y, size.z) - const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) - - // Простое позиционирование камеры - всегда поворачиваемся к датчику - console.log('[ModelViewer] Calculating camera direction to sensor') - - // Вычисляем направление от текущей позиции камеры к датчику - const directionToSensor = center.subtract(camera.position).normalize() - - // Преобразуем в сферические координаты - // alpha - горизонтальный угол (вокруг оси Y) - let targetAlpha = Math.atan2(directionToSensor.x, directionToSensor.z) - - // beta - вертикальный угол (от вертикали) - // Используем оптимальный угол 60° для обзора - let targetBeta = Math.PI / 3 // 60° - - console.log('[ModelViewer] Calculated camera direction:', { - alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°', - beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°', - sensorPosition: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) }, - cameraPosition: { x: camera.position.x.toFixed(2), y: camera.position.y.toFixed(2), z: camera.position.z.toFixed(2) } - }) - - // Нормализуем alpha в диапазон [-PI, PI] - while (targetAlpha > Math.PI) targetAlpha -= 2 * Math.PI - while (targetAlpha < -Math.PI) targetAlpha += 2 * Math.PI - - // Ограничиваем beta в разумных пределах - targetBeta = Math.max(0.1, Math.min(Math.PI - 0.1, targetBeta)) - - scene.stopAnimation(camera) - - // Логирование перед анимацией - console.log('[ModelViewer] Starting camera animation:', { - sensorId, - from: { - target: { x: camera.target.x.toFixed(2), y: camera.target.y.toFixed(2), z: camera.target.z.toFixed(2) }, - radius: camera.radius.toFixed(2), - alpha: (camera.alpha * 180 / Math.PI).toFixed(1) + '°', - beta: (camera.beta * 180 / Math.PI).toFixed(1) + '°' - }, - to: { - target: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) }, - radius: targetRadius.toFixed(2), - alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°', - beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°' - }, - alphaChange: ((targetAlpha - camera.alpha) * 180 / Math.PI).toFixed(1) + '°', - betaChange: ((targetBeta - camera.beta) * 180 / Math.PI).toFixed(1) + '°' - }) - - const ease = new CubicEase() - ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) - const frameRate = 60 - const durationMs = 800 - const totalFrames = Math.round((durationMs / 1000) * frameRate) - - Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease) - Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) - Animation.CreateAndStartAnimation('camAlpha', camera, 'alpha', frameRate, totalFrames, camera.alpha, targetAlpha, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) - Animation.CreateAndStartAnimation('camBeta', camera, 'beta', frameRate, totalFrames, camera.beta, targetBeta, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) - - applyHighlightToMeshes( - highlightLayerRef.current, - highlightedMeshesRef, - [chosen], - mesh => { - const sid = getSensorIdFromMesh(mesh) - const status = sid ? sensorStatusMap?.[sid] : undefined - return statusToColor3(status ?? null) - }, - ) - chosenMeshRef.current = chosen - setOverlayData({ name: chosen.name, sensorId }) - } catch { - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - highlightLayerRef.current?.removeAllMeshes() - chosenMeshRef.current = null - setOverlayPos(null) - setOverlayData(null) - } - } else { - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - highlightLayerRef.current?.removeAllMeshes() - chosenMeshRef.current = null - setOverlayPos(null) - setOverlayData(null) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [focusSensorId, modelReady, highlightAllSensors]) - - useEffect(() => { - const scene = sceneRef.current - if (!scene || !modelReady || !isSensorSelectionEnabled) return - - const pickObserver = scene.onPointerObservable.add((pointerInfo: PointerInfo) => { - if (pointerInfo.type !== PointerEventTypes.POINTERPICK) return - const pick = pointerInfo.pickInfo - if (!pick || !pick.hit) { - onSensorPick?.(null) - return - } - - const pickedMesh = pick.pickedMesh - const sensorId = getSensorIdFromMesh(pickedMesh) - - if (sensorId) { - onSensorPick?.(sensorId) - } else { - onSensorPick?.(null) - } - }) - - return () => { - scene.onPointerObservable.remove(pickObserver) - } - }, [modelReady, isSensorSelectionEnabled, onSensorPick]) - - const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => { - if (!sceneRef.current || !mesh) return null - const scene = sceneRef.current - try { - const bbox = (typeof mesh.getHierarchyBoundingVectors === 'function') - ? mesh.getHierarchyBoundingVectors() - : { min: mesh.getBoundingInfo().boundingBox.minimumWorld, max: mesh.getBoundingInfo().boundingBox.maximumWorld } - const center = bbox.min.add(bbox.max).scale(0.5) - - const viewport = scene.activeCamera?.viewport.toGlobal(engineRef.current!.getRenderWidth(), engineRef.current!.getRenderHeight()) - if (!viewport) return null - - const projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport) - if (!projected) return null - - return { left: projected.x, top: projected.y } - } catch (error) { - console.error('[ModelViewer] Error computing overlay position:', error) - return null - } - }, []) - - useEffect(() => { - if (!chosenMeshRef.current || !overlayData) return - const pos = computeOverlayPosition(chosenMeshRef.current) - setOverlayPos(pos) - }, [overlayData, computeOverlayPosition]) - - useEffect(() => { - if (!sceneRef.current || !chosenMeshRef.current || !overlayData) return - const scene = sceneRef.current - - const updateOverlayPosition = () => { - const pos = computeOverlayPosition(chosenMeshRef.current) - setOverlayPos(pos) - } - scene.registerBeforeRender(updateOverlayPosition) - return () => scene.unregisterBeforeRender(updateOverlayPosition) - }, [overlayData, computeOverlayPosition]) - - return ( -
- {!modelPath ? ( -
-
-
- 3D модель не выбрана -
-
- Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр -
-
- Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API -
-
-
- ) : ( - <> - - {webglError ? ( -
-
-
- 3D просмотр недоступен -
-
- {webglError} -
-
- Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве -
-
-
- ) : isLoading ? ( -
- -
- ) : !modelReady ? ( -
-
-
- 3D модель не загружена -
-
- Модель не готова к отображению -
-
-
- ) : null} - - - - )} - {/* UPDATED: Interactive overlay circles with hover effects */} - {allSensorsOverlayCircles.map(circle => { - const size = 36 - const radius = size / 2 - const fill = hexWithAlpha(circle.colorHex, 0.2) - const isHovered = hoveredSensorId === circle.sensorId - - return ( -
handleOverlayCircleClick(circle.sensorId)} - onMouseEnter={() => setHoveredSensorId(circle.sensorId)} - onMouseLeave={() => setHoveredSensorId(null)} - style={{ - position: 'absolute', - left: circle.left - radius, - top: circle.top - radius, - width: size, - height: size, - borderRadius: '9999px', - border: `2px solid ${circle.colorHex}`, - backgroundColor: fill, - pointerEvents: 'auto', - cursor: 'pointer', - transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)', - transform: isHovered ? 'scale(1.4)' : 'scale(1)', - boxShadow: isHovered - ? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}` - : `0 0 8px ${circle.colorHex}`, - zIndex: isHovered ? 50 : 10, - }} - title={`Датчик: ${circle.sensorId}`} - /> - ) - })} - {renderOverlay && overlayPos && overlayData - ? renderOverlay({ anchor: overlayPos, info: overlayData }) - : null - } -
- ) -} - -export default ModelViewer diff --git a/frontend/components/model/sensorHighlight.ts b/frontend/components/model/sensorHighlight.ts index 09cd3e6..8ba17e6 100644 --- a/frontend/components/model/sensorHighlight.ts +++ b/frontend/components/model/sensorHighlight.ts @@ -59,11 +59,24 @@ export const getSensorIdFromMesh = (m: AbstractMesh | null): string | null => { return null } -export const collectSensorMeshes = (meshes: AbstractMesh[]): AbstractMesh[] => { +export const collectSensorMeshes = ( + meshes: AbstractMesh[], + sensorStatusMap?: Record | null +): AbstractMesh[] => { const result: AbstractMesh[] = [] for (const m of meshes) { const sid = getSensorIdFromMesh(m) - if (sid) result.push(m) + if (sid) { + // Если передана карта статусов - фильтруем только по датчикам из неё + if (sensorStatusMap) { + if (sid in sensorStatusMap) { + result.push(m) + } + } else { + // Если карта не передана - возвращаем все датчики (старое поведение) + result.push(m) + } + } } return result } diff --git a/frontend/components/model/sensorHighlight — копия.ts b/frontend/components/model/sensorHighlight — копия.ts new file mode 100644 index 0000000..09cd3e6 --- /dev/null +++ b/frontend/components/model/sensorHighlight — копия.ts @@ -0,0 +1,96 @@ +import { + AbstractMesh, + HighlightLayer, + Mesh, + InstancedMesh, + Color3, +} from '@babylonjs/core' +import * as statusColors from '../../lib/statusColors' + +export const SENSOR_HIGHLIGHT_COLOR = new Color3(1, 1, 0) + +const CRITICAL_COLOR3 = Color3.FromHexString(statusColors.STATUS_COLOR_CRITICAL) +const WARNING_COLOR3 = Color3.FromHexString(statusColors.STATUS_COLOR_WARNING) +const NORMAL_COLOR3 = Color3.FromHexString(statusColors.STATUS_COLOR_NORMAL) + +export const statusToColor3 = (status?: string | null): Color3 => { + if (!status) return SENSOR_HIGHLIGHT_COLOR + const lower = status.toLowerCase() + if (lower === 'critical') { + return CRITICAL_COLOR3 + } + if (lower === 'warning') { + return WARNING_COLOR3 + } + if (lower === 'info' || lower === 'normal' || lower === 'ok') { + return NORMAL_COLOR3 + } + if (status === statusColors.STATUS_COLOR_CRITICAL) return CRITICAL_COLOR3 + if (status === statusColors.STATUS_COLOR_WARNING) return WARNING_COLOR3 + if (status === statusColors.STATUS_COLOR_NORMAL) return NORMAL_COLOR3 + return SENSOR_HIGHLIGHT_COLOR +} + +export const getSensorIdFromMesh = (m: AbstractMesh | null): string | null => { + if (!m) return null + try { + const meta: any = (m as any)?.metadata + const extras: any = meta?.gltf?.extras ?? meta?.extras ?? (m as any)?.extras + const sid = + extras?.Sensor_ID ?? + extras?.sensor_id ?? + extras?.SERIAL_NUMBER ?? + extras?.serial_number ?? + extras?.detector_id + if (sid != null) return String(sid).trim() + const monitoringSensorInstance = extras?.bonsaiPset_ARBM_PSet_MonitoringSensor_Instance + if (monitoringSensorInstance && typeof monitoringSensorInstance === 'string') { + try { + const parsedInstance = JSON.parse(monitoringSensorInstance) + const instanceSensorId = parsedInstance?.Sensor_ID + if (instanceSensorId != null) return String(instanceSensorId).trim() + } catch { + return null + } + } + } catch { + return null + } + return null +} + +export const collectSensorMeshes = (meshes: AbstractMesh[]): AbstractMesh[] => { + const result: AbstractMesh[] = [] + for (const m of meshes) { + const sid = getSensorIdFromMesh(m) + if (sid) result.push(m) + } + return result +} + +export const applyHighlightToMeshes = ( + layer: HighlightLayer | null, + highlightedRef: { current: AbstractMesh[] }, + meshesToHighlight: AbstractMesh[], + getColor?: (mesh: AbstractMesh) => Color3 | null, +) => { + if (!layer) return + for (const m of highlightedRef.current) { + m.renderingGroupId = 0 + } + highlightedRef.current = [] + layer.removeAllMeshes() + + meshesToHighlight.forEach(mesh => { + const color = getColor ? getColor(mesh) ?? SENSOR_HIGHLIGHT_COLOR : SENSOR_HIGHLIGHT_COLOR + if (mesh instanceof Mesh) { + mesh.renderingGroupId = 1 + highlightedRef.current.push(mesh) + layer.addMesh(mesh, color) + } else if (mesh instanceof InstancedMesh) { + mesh.sourceMesh.renderingGroupId = 1 + highlightedRef.current.push(mesh.sourceMesh) + layer.addMesh(mesh.sourceMesh, color) + } + }) +} diff --git a/frontend/components/navigation/DetectorMenu.tsx b/frontend/components/navigation/DetectorMenu.tsx index 25d6c6b..9d019d7 100644 --- a/frontend/components/navigation/DetectorMenu.tsx +++ b/frontend/components/navigation/DetectorMenu.tsx @@ -71,14 +71,18 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose, // Группируем уведомления по дням за последний месяц const now = new Date() - const monthAgo = new Date(now.getTime() - DAYS_COUNT * 24 * 60 * 60 * 1000) + // Устанавливаем время на начало текущего дня для корректного подсчёта + now.setHours(0, 0, 0, 0) + + // Начальная дата: DAYS_COUNT-1 дней назад (чтобы включить текущий день) + const startDate = new Date(now.getTime() - (DAYS_COUNT - 1) * 24 * 60 * 60 * 1000) // Создаём карту: дата -> { critical: count, warning: count } const dayMap: Record = {} - // Инициализируем все дни нулями + // Инициализируем все дни нулями (включая текущий день) for (let i = 0; i < DAYS_COUNT; i++) { - const date = new Date(monthAgo.getTime() + i * 24 * 60 * 60 * 1000) + const date = new Date(startDate.getTime() + i * 24 * 60 * 60 * 1000) const dateKey = date.toISOString().split('T')[0] dayMap[dateKey] = { critical: 0, warning: 0 } } @@ -91,7 +95,9 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose, } const notifDate = new Date(notification.timestamp) - if (notifDate >= monthAgo && notifDate <= now) { + // Проверяем что уведомление попадает в диапазон от startDate до конца текущего дня + const endOfToday = new Date(now.getTime() + 24 * 60 * 60 * 1000) + if (notifDate >= startDate && notifDate < endOfToday) { const dateKey = notifDate.toISOString().split('T')[0] if (dayMap[dateKey]) { const notifType = String(notification.type || '').toLowerCase() @@ -121,6 +127,8 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose, // Определение типа детектора и его отображаемого названия const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase() + + // Извлекаем код из текстового поля type const deriveCodeFromType = (): string => { const t = (detector.type || '').toLowerCase() if (!t) return '' @@ -129,7 +137,15 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose, if (t.includes('гидроуров')) return 'GLE' return '' } - const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType() + + // Fallback: извлекаем код из префикса серийного номера (GLE-3 -> GLE) + const deriveCodeFromSerialNumber = (): string => { + const serial = (detector.serial_number || '').toUpperCase() + if (!serial) return '' + // Ищем префикс до дефиса или цифры + const match = serial.match(/^([A-Z]+)[-\d]/) + return match ? match[1] : '' + } // Карта соответствия кодов типов детекторов их русским названиям const detectorTypeLabelMap: Record = { @@ -137,6 +153,15 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose, PE: 'Тензометр', GLE: 'Гидроуровень', } + + // Определяем эффективный код типа датчика с fallback + let effectiveDetectorTypeCode = rawDetectorTypeCode + + // Если rawDetectorTypeCode не найден в карте - используем fallback + if (!detectorTypeLabelMap[effectiveDetectorTypeCode]) { + effectiveDetectorTypeCode = deriveCodeFromType() || deriveCodeFromSerialNumber() + } + const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—' // Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором diff --git a/frontend/components/navigation/DetectorMenu.tsx — копия 2 b/frontend/components/navigation/DetectorMenu.tsx — копия 2 deleted file mode 100644 index 99d3f28..0000000 --- a/frontend/components/navigation/DetectorMenu.tsx — копия 2 +++ /dev/null @@ -1,296 +0,0 @@ -'use client' - -import React from 'react' -import { useRouter } from 'next/navigation' -import useNavigationStore from '@/app/store/navigationStore' - -interface DetectorType { - detector_id: number - name: string - serial_number: string - object: string - status: string - checked: boolean - type: string - detector_type: string - location: string - floor: number - notifications: Array<{ - id: number - type: string - message: string - timestamp: string - acknowledged: boolean - priority: string - }> -} - -interface DetectorMenuProps { - detector: DetectorType - isOpen: boolean - onClose: () => void - getStatusText: (status: string) => string - compact?: boolean - anchor?: { left: number; top: number } | null -} - -// Главный компонент меню детектора -// Показывает детальную информацию о датчике с возможностью навигации к отчетам и истории -const DetectorMenu: React.FC = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => { - const router = useRouter() - const { setSelectedDetector, currentObject } = useNavigationStore() - if (!isOpen) return null - - // Определение последней временной метки из уведомлений детектора - const latestTimestamp = (() => { - const list = detector.notifications ?? [] - if (!Array.isArray(list) || list.length === 0) return null - const dates = list.map(n => new Date(n.timestamp)).filter(d => !isNaN(d.getTime())) - if (dates.length === 0) return null - dates.sort((a, b) => b.getTime() - a.getTime()) - return dates[0] - })() - const formattedTimestamp = latestTimestamp - ? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) - : 'Нет данных' - - // Определение типа детектора и его отображаемого названия - const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase() - const deriveCodeFromType = (): string => { - const t = (detector.type || '').toLowerCase() - if (!t) return '' - if (t.includes('инклинометр')) return 'GA' - if (t.includes('тензометр')) return 'PE' - if (t.includes('гидроуров')) return 'GLE' - return '' - } - const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType() - - // Карта соответствия кодов типов детекторов их русским названиям - const detectorTypeLabelMap: Record = { - GA: 'Инклинометр', - PE: 'Тензометр', - GLE: 'Гидроуровень', - } - const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—' - - // Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором - const handleReportsClick = () => { - const currentUrl = new URL(window.location.href) - const objectId = currentUrl.searchParams.get('objectId') || currentObject.id - const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title - - const detectorData = { - ...detector, - notifications: detector.notifications || [] - } - setSelectedDetector(detectorData) - - let reportsUrl = '/reports' - const params = new URLSearchParams() - - if (objectId) params.set('objectId', objectId) - if (objectTitle) params.set('objectTitle', objectTitle) - - if (params.toString()) { - reportsUrl += `?${params.toString()}` - } - - router.push(reportsUrl) - } - - // Обработчик клика по кнопке "История" - навигация на страницу истории тревог с выбранным детектором - const handleHistoryClick = () => { - const currentUrl = new URL(window.location.href) - const objectId = currentUrl.searchParams.get('objectId') || currentObject.id - const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title - - const detectorData = { - ...detector, - notifications: detector.notifications || [] - } - setSelectedDetector(detectorData) - - let alertsUrl = '/alerts' - const params = new URLSearchParams() - - if (objectId) params.set('objectId', objectId) - if (objectTitle) params.set('objectTitle', objectTitle) - - if (params.toString()) { - alertsUrl += `?${params.toString()}` - } - - router.push(alertsUrl) - } - - // Компонент секции деталей детектора - // Отображает информацию о датчике в компактном или полном формате - const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => ( -
- {compact ? ( - // Компактный режим: 4 строки по 2 колонки с основной информацией - <> - {/* Строка 1: Маркировка и тип детектора */} -
-
-
Маркировка по проекту
-
{detector.name}
-
-
-
Тип детектора
-
{displayDetectorTypeLabel}
-
-
- {/* Строка 2: Местоположение и статус */} -
-
-
Местоположение
-
{detector.location}
-
-
-
Статус
-
{getStatusText(detector.status)}
-
-
- {/* Строка 3: Временная метка и этаж */} -
-
-
Временная метка
-
{formattedTimestamp}
-
-
-
Этаж
-
{detector.floor}
-
-
- {/* Строка 4: Серийный номер */} -
-
-
Серийный номер
-
{detector.serial_number}
-
-
- - ) : ( - // Полный режим: 3 строки по 2 колонки с рамками между элементами - <> - {/* Строка 1: Маркировка по проекту и тип детектора */} -
-
-
Маркировка по проекту
-
{detector.name}
-
-
-
Тип детектора
-
{displayDetectorTypeLabel}
-
-
- {/* Строка 2: Местоположение и статус */} -
-
-
Местоположение
-
{detector.location}
-
-
-
Статус
-
{getStatusText(detector.status)}
-
-
- {/* Строка 3: Временная метка и серийный номер */} -
-
-
Временная метка
-
{formattedTimestamp}
-
-
-
Серийный номер
-
{detector.serial_number}
-
-
- - )} -
- ) - - // Компактный режим с якорной позицией (всплывающее окно) - // Используется для отображения информации при наведении на детектор в списке - if (compact && anchor) { - return ( -
-
-
-
-
{detector.name}
-
- -
-
- - -
- -
-
- ) - } - - // Полный режим боковой панели (основной режим) - // Отображается как правая панель с полной информацией о детекторе - return ( -
-
-
- {/* Заголовок с названием детектора */} -

- {detector.name} -

- {/* Кнопки действий: Отчет и История */} -
- - -
-
- - {/* Секция с детальной информацией о детекторе */} - - - {/* Кнопка закрытия панели */} - -
-
- ) -} - -export default DetectorMenu \ No newline at end of file diff --git a/frontend/components/navigation/DetectorMenu.tsx — копия 3 b/frontend/components/navigation/DetectorMenu.tsx — копия 3 deleted file mode 100644 index 30b39f8..0000000 --- a/frontend/components/navigation/DetectorMenu.tsx — копия 3 +++ /dev/null @@ -1,329 +0,0 @@ -'use client' - -import React from 'react' -import { useRouter } from 'next/navigation' -import useNavigationStore from '@/app/store/navigationStore' -import AreaChart from '../dashboard/AreaChart' - -interface DetectorType { - detector_id: number - name: string - serial_number: string - object: string - status: string - checked: boolean - type: string - detector_type: string - location: string - floor: number - notifications: Array<{ - id: number - type: string - message: string - timestamp: string - acknowledged: boolean - priority: string - }> -} - -interface DetectorMenuProps { - detector: DetectorType - isOpen: boolean - onClose: () => void - getStatusText: (status: string) => string - compact?: boolean - anchor?: { left: number; top: number } | null -} - -// Главный компонент меню детектора -// Показывает детальную информацию о датчике с возможностью навигации к отчетам и истории -const DetectorMenu: React.FC = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => { - const router = useRouter() - const { setSelectedDetector, currentObject } = useNavigationStore() - if (!isOpen) return null - - // Определение последней временной метки из уведомлений детектора - const latestTimestamp = (() => { - const list = detector.notifications ?? [] - if (!Array.isArray(list) || list.length === 0) return null - const dates = list.map(n => new Date(n.timestamp)).filter(d => !isNaN(d.getTime())) - if (dates.length === 0) return null - dates.sort((a, b) => b.getTime() - a.getTime()) - return dates[0] - })() - const formattedTimestamp = latestTimestamp - ? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) - : 'Нет данных' - - // Данные для графика за последние 3 дня (мок данные) - const chartData: { timestamp: string; value: number }[] = [ - { timestamp: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), value: 75 }, - { timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), value: 82 }, - { timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), value: 78 }, - { timestamp: new Date().toISOString(), value: 85 }, - ] - - // Определение типа детектора и его отображаемого названия - const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase() - const deriveCodeFromType = (): string => { - const t = (detector.type || '').toLowerCase() - if (!t) return '' - if (t.includes('инклинометр')) return 'GA' - if (t.includes('тензометр')) return 'PE' - if (t.includes('гидроуров')) return 'GLE' - return '' - } - const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType() - - // Карта соответствия кодов типов детекторов их русским названиям - const detectorTypeLabelMap: Record = { - GA: 'Инклинометр', - PE: 'Тензометр', - GLE: 'Гидроуровень', - } - const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—' - - // Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором - const handleReportsClick = () => { - const currentUrl = new URL(window.location.href) - const objectId = currentUrl.searchParams.get('objectId') || currentObject.id - const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title - - const detectorData = { - ...detector, - notifications: detector.notifications || [] - } - setSelectedDetector(detectorData) - - let reportsUrl = '/reports' - const params = new URLSearchParams() - - if (objectId) params.set('objectId', objectId) - if (objectTitle) params.set('objectTitle', objectTitle) - - if (params.toString()) { - reportsUrl += `?${params.toString()}` - } - - router.push(reportsUrl) - } - - // Обработчик клика по кнопке "История" - навигация на страницу истории тревог с выбранным детектором - const handleHistoryClick = () => { - const currentUrl = new URL(window.location.href) - const objectId = currentUrl.searchParams.get('objectId') || currentObject.id - const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title - - const detectorData = { - ...detector, - notifications: detector.notifications || [] - } - setSelectedDetector(detectorData) - - let alertsUrl = '/alerts' - const params = new URLSearchParams() - - if (objectId) params.set('objectId', objectId) - if (objectTitle) params.set('objectTitle', objectTitle) - - if (params.toString()) { - alertsUrl += `?${params.toString()}` - } - - router.push(alertsUrl) - } - - // Компонент секции деталей детектора - // Отображает информацию о датчике в компактном или полном формате - const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => ( -
- {compact ? ( - // Компактный режим: 4 строки по 2 колонки с основной информацией - <> - {/* Строка 1: Маркировка и тип детектора */} -
-
-
Маркировка по проекту
-
{detector.name}
-
-
-
Тип детектора
-
{displayDetectorTypeLabel}
-
-
- {/* Строка 2: Местоположение и статус */} -
-
-
Местоположение
-
{detector.location}
-
-
-
Статус
-
{getStatusText(detector.status)}
-
-
- {/* Строка 3: Временная метка и этаж */} -
-
-
Временная метка
-
{formattedTimestamp}
-
-
-
Этаж
-
{detector.floor}
-
-
- {/* Строка 4: Серийный номер */} -
-
-
Серийный номер
-
{detector.serial_number}
-
-
- - ) : ( - // Полный режим: 3 строки по 2 колонки с рамками между элементами - <> - {/* Строка 1: Маркировка по проекту и тип детектора */} -
-
-
Маркировка по проекту
-
{detector.name}
-
-
-
Тип детектора
-
{displayDetectorTypeLabel}
-
-
- {/* Строка 2: Местоположение и статус */} -
-
-
Местоположение
-
{detector.location}
-
-
-
Статус
-
{getStatusText(detector.status)}
-
-
- {/* Строка 3: Временная метка и серийный номер */} -
-
-
Временная метка
-
{formattedTimestamp}
-
-
-
Серийный номер
-
{detector.serial_number}
-
-
- - )} -
- ) - - // Компактный режим с якорной позицией (всплывающее окно) - // Используется для отображения информации при наведении на детектор в списке - if (compact && anchor) { - // Проверяем границы экрана и корректируем позицию - const tooltipHeight = 450 // Примерная высота толтипа с графиком - const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800 - const bottomOverflow = anchor.top + tooltipHeight - viewportHeight - - // Если толтип выходит за нижнюю границу, сдвигаем вверх - const adjustedTop = bottomOverflow > 0 ? anchor.top - bottomOverflow - 20 : anchor.top - - return ( -
-
-
-
-
{detector.name}
-
- -
-
- - -
- - - {/* График за последние 3 дня */} -
-
График за 3 дня
-
- -
-
-
-
- ) - } - - // Полный режим боковой панели (основной режим) - // Отображается как правая панель с полной информацией о детекторе - return ( -
-
-
- {/* Заголовок с названием детектора */} -

- {detector.name} -

- {/* Кнопки действий: Отчет и История */} -
- - -
-
- - {/* Секция с детальной информацией о детекторе */} - - - {/* График за последние 3 дня */} -
-

График за последние 3 дня

-
- -
-
- - {/* Кнопка закрытия панели */} - -
-
- ) -} - -export default DetectorMenu \ No newline at end of file diff --git a/frontend/components/navigation/DetectorMenu.tsx — копия 4 b/frontend/components/navigation/DetectorMenu.tsx — копия 4 deleted file mode 100644 index 356f1b6..0000000 --- a/frontend/components/navigation/DetectorMenu.tsx — копия 4 +++ /dev/null @@ -1,329 +0,0 @@ -'use client' - -import React from 'react' -import { useRouter } from 'next/navigation' -import useNavigationStore from '@/app/store/navigationStore' -import AreaChart from '../dashboard/AreaChart' - -interface DetectorType { - detector_id: number - name: string - serial_number: string - object: string - status: string - checked: boolean - type: string - detector_type: string - location: string - floor: number - notifications: Array<{ - id: number - type: string - message: string - timestamp: string - acknowledged: boolean - priority: string - }> -} - -interface DetectorMenuProps { - detector: DetectorType - isOpen: boolean - onClose: () => void - getStatusText: (status: string) => string - compact?: boolean - anchor?: { left: number; top: number } | null -} - -// Главный компонент меню детектора -// Показывает детальную информацию о датчике с возможностью навигации к отчетам и истории -const DetectorMenu: React.FC = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => { - const router = useRouter() - const { setSelectedDetector, currentObject } = useNavigationStore() - if (!isOpen) return null - - // Определение последней временной метки из уведомлений детектора - const latestTimestamp = (() => { - const list = detector.notifications ?? [] - if (!Array.isArray(list) || list.length === 0) return null - const dates = list.map(n => new Date(n.timestamp)).filter(d => !isNaN(d.getTime())) - if (dates.length === 0) return null - dates.sort((a, b) => b.getTime() - a.getTime()) - return dates[0] - })() - const formattedTimestamp = latestTimestamp - ? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) - : 'Нет данных' - - // Данные для графика за последние 3 дня (мок данные) - const chartData = [ - { timestamp: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), critical: 75, warning: 0 }, - { timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), critical: 82, warning: 0 }, - { timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), critical: 78, warning: 0 }, - { timestamp: new Date().toISOString(), critical: 85, warning: 0 }, - ] - - // Определение типа детектора и его отображаемого названия - const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase() - const deriveCodeFromType = (): string => { - const t = (detector.type || '').toLowerCase() - if (!t) return '' - if (t.includes('инклинометр')) return 'GA' - if (t.includes('тензометр')) return 'PE' - if (t.includes('гидроуров')) return 'GLE' - return '' - } - const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType() - - // Карта соответствия кодов типов детекторов их русским названиям - const detectorTypeLabelMap: Record = { - GA: 'Инклинометр', - PE: 'Тензометр', - GLE: 'Гидроуровень', - } - const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—' - - // Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором - const handleReportsClick = () => { - const currentUrl = new URL(window.location.href) - const objectId = currentUrl.searchParams.get('objectId') || currentObject.id - const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title - - const detectorData = { - ...detector, - notifications: detector.notifications || [] - } - setSelectedDetector(detectorData) - - let reportsUrl = '/reports' - const params = new URLSearchParams() - - if (objectId) params.set('objectId', objectId) - if (objectTitle) params.set('objectTitle', objectTitle) - - if (params.toString()) { - reportsUrl += `?${params.toString()}` - } - - router.push(reportsUrl) - } - - // Обработчик клика по кнопке "История" - навигация на страницу истории тревог с выбранным детектором - const handleHistoryClick = () => { - const currentUrl = new URL(window.location.href) - const objectId = currentUrl.searchParams.get('objectId') || currentObject.id - const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title - - const detectorData = { - ...detector, - notifications: detector.notifications || [] - } - setSelectedDetector(detectorData) - - let alertsUrl = '/alerts' - const params = new URLSearchParams() - - if (objectId) params.set('objectId', objectId) - if (objectTitle) params.set('objectTitle', objectTitle) - - if (params.toString()) { - alertsUrl += `?${params.toString()}` - } - - router.push(alertsUrl) - } - - // Компонент секции деталей детектора - // Отображает информацию о датчике в компактном или полном формате - const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => ( -
- {compact ? ( - // Компактный режим: 4 строки по 2 колонки с основной информацией - <> - {/* Строка 1: Маркировка и тип детектора */} -
-
-
Маркировка по проекту
-
{detector.name}
-
-
-
Тип детектора
-
{displayDetectorTypeLabel}
-
-
- {/* Строка 2: Местоположение и статус */} -
-
-
Местоположение
-
{detector.location}
-
-
-
Статус
-
{getStatusText(detector.status)}
-
-
- {/* Строка 3: Временная метка и этаж */} -
-
-
Временная метка
-
{formattedTimestamp}
-
-
-
Этаж
-
{detector.floor}
-
-
- {/* Строка 4: Серийный номер */} -
-
-
Серийный номер
-
{detector.serial_number}
-
-
- - ) : ( - // Полный режим: 3 строки по 2 колонки с рамками между элементами - <> - {/* Строка 1: Маркировка по проекту и тип детектора */} -
-
-
Маркировка по проекту
-
{detector.name}
-
-
-
Тип детектора
-
{displayDetectorTypeLabel}
-
-
- {/* Строка 2: Местоположение и статус */} -
-
-
Местоположение
-
{detector.location}
-
-
-
Статус
-
{getStatusText(detector.status)}
-
-
- {/* Строка 3: Временная метка и серийный номер */} -
-
-
Временная метка
-
{formattedTimestamp}
-
-
-
Серийный номер
-
{detector.serial_number}
-
-
- - )} -
- ) - - // Компактный режим с якорной позицией (всплывающее окно) - // Используется для отображения информации при наведении на детектор в списке - if (compact && anchor) { - // Проверяем границы экрана и корректируем позицию - const tooltipHeight = 450 // Примерная высота толтипа с графиком - const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800 - const bottomOverflow = anchor.top + tooltipHeight - viewportHeight - - // Если толтип выходит за нижнюю границу, сдвигаем вверх - const adjustedTop = bottomOverflow > 0 ? anchor.top - bottomOverflow - 20 : anchor.top - - return ( -
-
-
-
-
{detector.name}
-
- -
-
- - -
- - - {/* График за последние 3 дня */} -
-
График за 3 дня
-
- -
-
-
-
- ) - } - - // Полный режим боковой панели (основной режим) - // Отображается как правая панель с полной информацией о детекторе - return ( -
-
-
- {/* Заголовок с названием детектора */} -

- {detector.name} -

- {/* Кнопки действий: Отчет и История */} -
- - -
-
- - {/* Секция с детальной информацией о детекторе */} - - - {/* График за последние 3 дня */} -
-

График за последние 3 дня

-
- -
-
- - {/* Кнопка закрытия панели */} - -
-
- ) -} - -export default DetectorMenu \ No newline at end of file