обновление бизнес логики
This commit is contained in:
@@ -44,8 +44,11 @@ class SensorView(APIView):
|
|||||||
)])})
|
)])})
|
||||||
@handle_exceptions
|
@handle_exceptions
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
"""Получение всех датчиков"""
|
"""Получение всех датчиков или датчиков конкретной зоны"""
|
||||||
try:
|
try:
|
||||||
|
# Получаем опциональный параметр zone_id из query string
|
||||||
|
zone_id = request.query_params.get('zone_id', None)
|
||||||
|
|
||||||
sensors = Sensor.objects.select_related(
|
sensors = Sensor.objects.select_related(
|
||||||
'sensor_type',
|
'sensor_type',
|
||||||
'signal_format'
|
'signal_format'
|
||||||
@@ -53,7 +56,14 @@ class SensorView(APIView):
|
|||||||
'zones',
|
'zones',
|
||||||
'zones__object',
|
'zones__object',
|
||||||
'alerts'
|
'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()
|
total_count = sensors.count()
|
||||||
print(f"[SensorView] Total sensors in DB: {total_count}")
|
print(f"[SensorView] Total sensors in DB: {total_count}")
|
||||||
|
|||||||
81
backend/api/account/views/sensors_views — копия 2.py
Normal file
81
backend/api/account/views/sensors_views — копия 2.py
Normal file
@@ -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)
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -40,7 +40,7 @@ class Channel(models.Model):
|
|||||||
|
|
||||||
class SensorType(models.Model):
|
class SensorType(models.Model):
|
||||||
"""Тип датчика: GA, PE, GLE"""
|
"""Тип датчика: 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)
|
name = models.CharField(max_length=100)
|
||||||
description = models.TextField(blank=True, null=True)
|
description = models.TextField(blank=True, null=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|||||||
200
backend/sitemanagement/models — копия.py
Normal file
200
backend/sitemanagement/models — копия.py
Normal file
@@ -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}"
|
||||||
|
|
||||||
@@ -215,7 +215,41 @@ const NavigationPage: React.FC = () => {
|
|||||||
const loadDetectors = async () => {
|
const loadDetectors = async () => {
|
||||||
try {
|
try {
|
||||||
setDetectorsError(null)
|
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()
|
const text = await res.text()
|
||||||
let payload: any
|
let payload: any
|
||||||
try { payload = JSON.parse(text) } catch { payload = text }
|
try { payload = JSON.parse(text) } catch { payload = text }
|
||||||
@@ -232,7 +266,7 @@ const NavigationPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadDetectors()
|
loadDetectors()
|
||||||
}, [])
|
}, [selectedModelPath, objectId])
|
||||||
|
|
||||||
const handleBackClick = () => {
|
const handleBackClick = () => {
|
||||||
router.push('/dashboard')
|
router.push('/dashboard')
|
||||||
|
|||||||
@@ -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<ModelViewerProps>(() => import('../../../components/model/ModelViewer'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="w-full h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-gray-300 animate-pulse">Загрузка 3D-модуля…</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
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<string, DetectorType> }>({ detectors: {} })
|
|
||||||
const [detectorsError, setDetectorsError] = useState<string | null>(null)
|
|
||||||
const [modelError, setModelError] = useState<string | null>(null)
|
|
||||||
const [isModelReady, setIsModelReady] = useState(false)
|
|
||||||
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
|
||||||
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
|
||||||
const sensorStatusMap = React.useMemo(() => {
|
|
||||||
const map: Record<string, string> = {}
|
|
||||||
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<string>(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<string, DetectorType>
|
|
||||||
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 (
|
|
||||||
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
|
||||||
<AnimatedBackground />
|
|
||||||
<div className="relative z-20">
|
|
||||||
<Sidebar
|
|
||||||
activeItem={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex-1 flex flex-col">
|
|
||||||
|
|
||||||
{showMonitoring && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Monitoring
|
|
||||||
onClose={closeMonitoring}
|
|
||||||
onSelectModel={(path) => {
|
|
||||||
console.log('[NavigationPage] Model selected:', path);
|
|
||||||
setSelectedModelPath(path)
|
|
||||||
setModelError(null)
|
|
||||||
setIsModelReady(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showFloorNavigation && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<FloorNavigation
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onDetectorMenuClick={handleDetectorMenuClick}
|
|
||||||
onClose={closeFloorNavigation}
|
|
||||||
is3DReady={isModelReady && !modelError}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNotifications && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Notifications
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onNotificationClick={handleNotificationClick}
|
|
||||||
onClose={closeNotifications}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showListOfDetectors && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<ListOfDetectors
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onDetectorMenuClick={handleDetectorMenuClick}
|
|
||||||
onClose={closeListOfDetectors}
|
|
||||||
is3DReady={selectedModelPath ? !modelError : false}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSensors && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Sensors
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onAlertClick={handleAlertClick}
|
|
||||||
onClose={closeSensors}
|
|
||||||
is3DReady={selectedModelPath ? !modelError : false}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNotifications && showNotificationDetectorInfo && selectedNotification && (() => {
|
|
||||||
const detectorData = Object.values(detectorsData.detectors).find(
|
|
||||||
detector => detector.detector_id === selectedNotification.detector_id
|
|
||||||
);
|
|
||||||
return detectorData ? (
|
|
||||||
<div className="absolute left-[500px] top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<NotificationDetectorInfo
|
|
||||||
detectorData={detectorData}
|
|
||||||
onClose={closeNotificationDetectorInfo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{showFloorNavigation && showDetectorMenu && selectedDetector && (
|
|
||||||
null
|
|
||||||
)}
|
|
||||||
|
|
||||||
<header className="bg-[#161824] border-b border-gray-700 px-6 h-[73px] flex items-center">
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button
|
|
||||||
onClick={handleBackClick}
|
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
|
||||||
aria-label="Назад к дашборду"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<nav className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-gray-400">Дашборд</span>
|
|
||||||
<span className="text-gray-600">{'/'}</span>
|
|
||||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
|
||||||
<span className="text-gray-600">{'/'}</span>
|
|
||||||
<span className="text-white">Навигация</span>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<div className="h-full">
|
|
||||||
{modelError ? (
|
|
||||||
<>
|
|
||||||
{console.log('[NavigationPage] Rendering error message, modelError:', modelError)}
|
|
||||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
|
||||||
<div className="text-red-400 text-lg font-semibold mb-4">
|
|
||||||
Ошибка загрузки 3D модели
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-300 mb-4">
|
|
||||||
{modelError}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
Используйте навигацию по этажам для просмотра детекторов
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : !selectedModelPath ? (
|
|
||||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-center p-8 flex flex-col items-center">
|
|
||||||
<Image
|
|
||||||
src="/icons/logo.png"
|
|
||||||
alt="AerBIM HT Monitor"
|
|
||||||
width={300}
|
|
||||||
height={41}
|
|
||||||
className="mb-6"
|
|
||||||
/>
|
|
||||||
<div className="text-gray-300 text-lg">
|
|
||||||
Выберите модель для отображения
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ModelViewer
|
|
||||||
key={selectedModelPath || 'no-model'}
|
|
||||||
modelPath={selectedModelPath}
|
|
||||||
onSelectModel={(path) => {
|
|
||||||
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 ? (
|
|
||||||
<AlertMenu
|
|
||||||
alert={selectedAlert}
|
|
||||||
isOpen={true}
|
|
||||||
onClose={closeAlertMenu}
|
|
||||||
getStatusText={getStatusText}
|
|
||||||
compact={true}
|
|
||||||
anchor={anchor}
|
|
||||||
/>
|
|
||||||
) : selectedDetector && showDetectorMenu && anchor ? (
|
|
||||||
<DetectorMenu
|
|
||||||
detector={selectedDetector}
|
|
||||||
isOpen={true}
|
|
||||||
onClose={closeDetectorMenu}
|
|
||||||
getStatusText={getStatusText}
|
|
||||||
compact={true}
|
|
||||||
anchor={anchor}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NavigationPage
|
|
||||||
@@ -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<ModelViewerProps>(() => import('../../../components/model/ModelViewer'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="w-full h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-gray-300 animate-pulse">Загрузка 3D-модуля…</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
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<string, DetectorType> }>({ detectors: {} })
|
|
||||||
const [detectorsError, setDetectorsError] = useState<string | null>(null)
|
|
||||||
const [modelError, setModelError] = useState<string | null>(null)
|
|
||||||
const [isModelReady, setIsModelReady] = useState(false)
|
|
||||||
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
|
||||||
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
|
||||||
const sensorStatusMap = React.useMemo(() => {
|
|
||||||
const map: Record<string, string> = {}
|
|
||||||
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<string>(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<string, DetectorType>
|
|
||||||
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 (
|
|
||||||
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
|
||||||
<AnimatedBackground />
|
|
||||||
<div className="relative z-20">
|
|
||||||
<Sidebar
|
|
||||||
activeItem={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex-1 flex flex-col">
|
|
||||||
|
|
||||||
{showMonitoring && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Monitoring
|
|
||||||
onClose={closeMonitoring}
|
|
||||||
onSelectModel={(path) => {
|
|
||||||
console.log('[NavigationPage] Model selected:', path);
|
|
||||||
setSelectedModelPath(path)
|
|
||||||
setModelError(null)
|
|
||||||
setIsModelReady(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showFloorNavigation && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<FloorNavigation
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onDetectorMenuClick={handleDetectorMenuClick}
|
|
||||||
onClose={closeFloorNavigation}
|
|
||||||
is3DReady={isModelReady && !modelError}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNotifications && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Notifications
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onNotificationClick={handleNotificationClick}
|
|
||||||
onClose={closeNotifications}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showListOfDetectors && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<ListOfDetectors
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onDetectorMenuClick={handleDetectorMenuClick}
|
|
||||||
onClose={closeListOfDetectors}
|
|
||||||
is3DReady={selectedModelPath ? !modelError : false}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSensors && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Sensors
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onAlertClick={handleAlertClick}
|
|
||||||
onClose={closeSensors}
|
|
||||||
is3DReady={selectedModelPath ? !modelError : false}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNotifications && showNotificationDetectorInfo && selectedNotification && (() => {
|
|
||||||
const detectorData = Object.values(detectorsData.detectors).find(
|
|
||||||
detector => detector.detector_id === selectedNotification.detector_id
|
|
||||||
);
|
|
||||||
return detectorData ? (
|
|
||||||
<div className="absolute left-[500px] top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<NotificationDetectorInfo
|
|
||||||
detectorData={detectorData}
|
|
||||||
onClose={closeNotificationDetectorInfo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{showFloorNavigation && showDetectorMenu && selectedDetector && (
|
|
||||||
null
|
|
||||||
)}
|
|
||||||
|
|
||||||
<header className="bg-[#161824] border-b border-gray-700 px-6 h-[73px] flex items-center">
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button
|
|
||||||
onClick={handleBackClick}
|
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
|
||||||
aria-label="Назад к дашборду"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<nav className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-gray-400">Дашборд</span>
|
|
||||||
<span className="text-gray-600">{'/'}</span>
|
|
||||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
|
||||||
<span className="text-gray-600">{'/'}</span>
|
|
||||||
<span className="text-white">Навигация</span>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<div className="h-full">
|
|
||||||
{modelError ? (
|
|
||||||
<>
|
|
||||||
{console.log('[NavigationPage] Rendering error message, modelError:', modelError)}
|
|
||||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
|
||||||
<div className="text-red-400 text-lg font-semibold mb-4">
|
|
||||||
Ошибка загрузки 3D модели
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-300 mb-4">
|
|
||||||
{modelError}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
Используйте навигацию по этажам для просмотра детекторов
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : !selectedModelPath ? (
|
|
||||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-center p-8 flex flex-col items-center">
|
|
||||||
<Image
|
|
||||||
src="/icons/logo.png"
|
|
||||||
alt="AerBIM HT Monitor"
|
|
||||||
width={300}
|
|
||||||
height={41}
|
|
||||||
className="mb-6"
|
|
||||||
/>
|
|
||||||
<div className="text-gray-300 text-lg">
|
|
||||||
Выберите модель для отображения
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ModelViewer
|
|
||||||
key={selectedModelPath || 'no-model'}
|
|
||||||
modelPath={selectedModelPath}
|
|
||||||
onSelectModel={(path) => {
|
|
||||||
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 ? (
|
|
||||||
<AlertMenu
|
|
||||||
alert={selectedAlert}
|
|
||||||
isOpen={true}
|
|
||||||
onClose={closeAlertMenu}
|
|
||||||
getStatusText={getStatusText}
|
|
||||||
compact={true}
|
|
||||||
anchor={anchor}
|
|
||||||
/>
|
|
||||||
) : selectedDetector && showDetectorMenu && anchor ? (
|
|
||||||
<DetectorMenu
|
|
||||||
detector={selectedDetector}
|
|
||||||
isOpen={true}
|
|
||||||
onClose={closeDetectorMenu}
|
|
||||||
getStatusText={getStatusText}
|
|
||||||
compact={true}
|
|
||||||
anchor={anchor}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NavigationPage
|
|
||||||
@@ -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<ModelViewerProps>(() => import('../../../components/model/ModelViewer'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="w-full h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-gray-300 animate-pulse">Загрузка 3D-модуля…</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
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<string, DetectorType> }>({ detectors: {} })
|
|
||||||
const [detectorsError, setDetectorsError] = useState<string | null>(null)
|
|
||||||
const [modelError, setModelError] = useState<string | null>(null)
|
|
||||||
const [isModelReady, setIsModelReady] = useState(false)
|
|
||||||
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
|
||||||
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
|
||||||
const sensorStatusMap = React.useMemo(() => {
|
|
||||||
// Создаём карту статусов всегда для отображения цветов датчиков
|
|
||||||
const map: Record<string, string> = {}
|
|
||||||
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<string>(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<string, DetectorType>
|
|
||||||
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 (
|
|
||||||
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
|
||||||
<AnimatedBackground />
|
|
||||||
<div className="relative z-20">
|
|
||||||
<Sidebar
|
|
||||||
activeItem={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex-1 flex flex-col">
|
|
||||||
|
|
||||||
{showMonitoring && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Monitoring
|
|
||||||
onClose={closeMonitoring}
|
|
||||||
onSelectModel={(path) => {
|
|
||||||
console.log('[NavigationPage] Model selected:', path);
|
|
||||||
setSelectedModelPath(path)
|
|
||||||
setModelError(null)
|
|
||||||
setIsModelReady(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showFloorNavigation && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<FloorNavigation
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onDetectorMenuClick={handleDetectorMenuClick}
|
|
||||||
onClose={closeFloorNavigation}
|
|
||||||
is3DReady={isModelReady && !modelError}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNotifications && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Notifications
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onNotificationClick={handleNotificationClick}
|
|
||||||
onClose={closeNotifications}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showListOfDetectors && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<ListOfDetectors
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onDetectorMenuClick={handleDetectorMenuClick}
|
|
||||||
onClose={closeListOfDetectors}
|
|
||||||
is3DReady={selectedModelPath ? !modelError : false}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSensors && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Sensors
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onAlertClick={handleAlertClick}
|
|
||||||
onClose={closeSensors}
|
|
||||||
is3DReady={selectedModelPath ? !modelError : false}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNotifications && showNotificationDetectorInfo && selectedNotification && (() => {
|
|
||||||
const detectorData = Object.values(detectorsData.detectors).find(
|
|
||||||
detector => detector.detector_id === selectedNotification.detector_id
|
|
||||||
);
|
|
||||||
return detectorData ? (
|
|
||||||
<div className="absolute left-[500px] top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<NotificationDetectorInfo
|
|
||||||
detectorData={detectorData}
|
|
||||||
onClose={closeNotificationDetectorInfo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{showFloorNavigation && showDetectorMenu && selectedDetector && (
|
|
||||||
null
|
|
||||||
)}
|
|
||||||
|
|
||||||
<header className="bg-[#161824] border-b border-gray-700 px-6 h-[73px] flex items-center">
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button
|
|
||||||
onClick={handleBackClick}
|
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
|
||||||
aria-label="Назад к дашборду"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<nav className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-gray-400">Дашборд</span>
|
|
||||||
<span className="text-gray-600">{'/'}</span>
|
|
||||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
|
||||||
<span className="text-gray-600">{'/'}</span>
|
|
||||||
<span className="text-white">Навигация</span>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<div className="h-full">
|
|
||||||
{modelError ? (
|
|
||||||
<>
|
|
||||||
{console.log('[NavigationPage] Rendering error message, modelError:', modelError)}
|
|
||||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
|
||||||
<div className="text-red-400 text-lg font-semibold mb-4">
|
|
||||||
Ошибка загрузки 3D модели
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-300 mb-4">
|
|
||||||
{modelError}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
Используйте навигацию по этажам для просмотра детекторов
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : !selectedModelPath ? (
|
|
||||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-center p-8 flex flex-col items-center">
|
|
||||||
<Image
|
|
||||||
src="/icons/logo.png"
|
|
||||||
alt="AerBIM HT Monitor"
|
|
||||||
width={300}
|
|
||||||
height={41}
|
|
||||||
className="mb-6"
|
|
||||||
/>
|
|
||||||
<div className="text-gray-300 text-lg">
|
|
||||||
Выберите модель для отображения
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ModelViewer
|
|
||||||
key={selectedModelPath || 'no-model'}
|
|
||||||
modelPath={selectedModelPath}
|
|
||||||
onSelectModel={(path) => {
|
|
||||||
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 ? (
|
|
||||||
<AlertMenu
|
|
||||||
alert={selectedAlert}
|
|
||||||
isOpen={true}
|
|
||||||
onClose={closeAlertMenu}
|
|
||||||
getStatusText={getStatusText}
|
|
||||||
compact={true}
|
|
||||||
anchor={anchor}
|
|
||||||
/>
|
|
||||||
) : selectedDetector && showDetectorMenu && anchor ? (
|
|
||||||
<DetectorMenu
|
|
||||||
detector={selectedDetector}
|
|
||||||
isOpen={true}
|
|
||||||
onClose={closeDetectorMenu}
|
|
||||||
getStatusText={getStatusText}
|
|
||||||
compact={true}
|
|
||||||
anchor={anchor}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NavigationPage
|
|
||||||
@@ -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<ModelViewerProps>(() => import('../../../components/model/ModelViewer'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="w-full h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-gray-300 animate-pulse">Загрузка 3D-модуля…</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
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<string, DetectorType> }>({ detectors: {} })
|
|
||||||
const [detectorsError, setDetectorsError] = useState<string | null>(null)
|
|
||||||
const [modelError, setModelError] = useState<string | null>(null)
|
|
||||||
const [isModelReady, setIsModelReady] = useState(false)
|
|
||||||
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
|
||||||
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
|
||||||
const [showStats, setShowStats] = useState(false)
|
|
||||||
const sensorStatusMap = React.useMemo(() => {
|
|
||||||
const map: Record<string, string> = {}
|
|
||||||
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<string>(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<string, DetectorType>
|
|
||||||
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 (
|
|
||||||
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
|
||||||
<AnimatedBackground />
|
|
||||||
<div className="relative z-20">
|
|
||||||
<Sidebar
|
|
||||||
activeItem={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex-1 flex flex-col">
|
|
||||||
|
|
||||||
{showMonitoring && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Monitoring
|
|
||||||
onClose={closeMonitoring}
|
|
||||||
onSelectModel={(path) => {
|
|
||||||
console.log('[NavigationPage] Model selected:', path);
|
|
||||||
setSelectedModelPath(path)
|
|
||||||
setModelError(null)
|
|
||||||
setIsModelReady(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showFloorNavigation && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<FloorNavigation
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onDetectorMenuClick={handleDetectorMenuClick}
|
|
||||||
onClose={closeFloorNavigation}
|
|
||||||
is3DReady={isModelReady && !modelError}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNotifications && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Notifications
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onNotificationClick={handleNotificationClick}
|
|
||||||
onClose={closeNotifications}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showListOfDetectors && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<ListOfDetectors
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onDetectorMenuClick={handleDetectorMenuClick}
|
|
||||||
onClose={closeListOfDetectors}
|
|
||||||
is3DReady={selectedModelPath ? !modelError : false}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSensors && (
|
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<Sensors
|
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
onAlertClick={handleAlertClick}
|
|
||||||
onClose={closeSensors}
|
|
||||||
is3DReady={selectedModelPath ? !modelError : false}
|
|
||||||
/>
|
|
||||||
{detectorsError && (
|
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNotifications && showNotificationDetectorInfo && selectedNotification && (() => {
|
|
||||||
const detectorData = Object.values(detectorsData.detectors).find(
|
|
||||||
detector => detector.detector_id === selectedNotification.detector_id
|
|
||||||
);
|
|
||||||
return detectorData ? (
|
|
||||||
<div className="absolute left-[500px] top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]">
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
<NotificationDetectorInfo
|
|
||||||
detectorData={detectorData}
|
|
||||||
onClose={closeNotificationDetectorInfo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{showFloorNavigation && showDetectorMenu && selectedDetector && (
|
|
||||||
null
|
|
||||||
)}
|
|
||||||
|
|
||||||
<header className="bg-[#161824] border-b border-gray-700 px-6 h-[73px] flex items-center">
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button
|
|
||||||
onClick={handleBackClick}
|
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
|
||||||
aria-label="Назад к дашборду"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<nav className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-gray-400">Дашборд</span>
|
|
||||||
<span className="text-gray-600">{'/'}</span>
|
|
||||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
|
||||||
<span className="text-gray-600">{'/'}</span>
|
|
||||||
<span className="text-white">Навигация</span>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<div className="h-full">
|
|
||||||
{modelError ? (
|
|
||||||
<>
|
|
||||||
{console.log('[NavigationPage] Rendering error message, modelError:', modelError)}
|
|
||||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
|
||||||
<div className="text-red-400 text-lg font-semibold mb-4">
|
|
||||||
Ошибка загрузки 3D модели
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-300 mb-4">
|
|
||||||
{modelError}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
Используйте навигацию по этажам для просмотра детекторов
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : !selectedModelPath ? (
|
|
||||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
|
||||||
<div className="text-center p-8 flex flex-col items-center">
|
|
||||||
<Image
|
|
||||||
src="/icons/logo.png"
|
|
||||||
alt="AerBIM HT Monitor"
|
|
||||||
width={300}
|
|
||||||
height={41}
|
|
||||||
className="mb-6"
|
|
||||||
/>
|
|
||||||
<div className="text-gray-300 text-lg">
|
|
||||||
Выберите модель для отображения
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ModelViewer
|
|
||||||
key={selectedModelPath || 'no-model'}
|
|
||||||
modelPath={selectedModelPath}
|
|
||||||
onSelectModel={(path) => {
|
|
||||||
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 ? (
|
|
||||||
<AlertMenu
|
|
||||||
alert={selectedAlert}
|
|
||||||
isOpen={true}
|
|
||||||
onClose={closeAlertMenu}
|
|
||||||
getStatusText={getStatusText}
|
|
||||||
compact={true}
|
|
||||||
anchor={anchor}
|
|
||||||
/>
|
|
||||||
) : selectedDetector && showDetectorMenu && anchor ? (
|
|
||||||
<DetectorMenu
|
|
||||||
detector={selectedDetector}
|
|
||||||
isOpen={true}
|
|
||||||
onClose={closeDetectorMenu}
|
|
||||||
getStatusText={getStatusText}
|
|
||||||
compact={true}
|
|
||||||
anchor={anchor}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NavigationPage
|
|
||||||
@@ -3,7 +3,7 @@ import { getServerSession } from 'next-auth'
|
|||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import * as statusColors from '@/lib/statusColors'
|
import * as statusColors from '@/lib/statusColors'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions)
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.accessToken) {
|
if (!session?.accessToken) {
|
||||||
@@ -11,9 +11,20 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const backendUrl = process.env.BACKEND_URL
|
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([
|
const [detectorsRes, objectsRes] = await Promise.all([
|
||||||
fetch(`${backendUrl}/account/get-detectors/`, {
|
fetch(detectorsUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${session.accessToken}`,
|
Authorization: `Bearer ${session.accessToken}`,
|
||||||
|
|||||||
117
frontend/app/api/get-detectors/route — копия 2.ts
Normal file
117
frontend/app/api/get-detectors/route — копия 2.ts
Normal file
@@ -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<string, string> = {}
|
||||||
|
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<string, string> = {
|
||||||
|
critical: statusColors.STATUS_COLOR_CRITICAL,
|
||||||
|
warning: statusColors.STATUS_COLOR_WARNING,
|
||||||
|
normal: statusColors.STATUS_COLOR_NORMAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformedDetectors: Record<string, any> = {}
|
||||||
|
const detectorsObj = detectorsPayload?.detectors ?? {}
|
||||||
|
for (const [key, sensor] of Object.entries<any>(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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ interface AreaChartProps {
|
|||||||
const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
||||||
const width = 635
|
const width = 635
|
||||||
const height = 280
|
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 plotWidth = width - margin.left - margin.right
|
||||||
const plotHeight = height - margin.top - margin.bottom
|
const plotHeight = height - margin.top - margin.bottom
|
||||||
const baselineY = margin.top + plotHeight
|
const baselineY = margin.top + plotHeight
|
||||||
@@ -222,15 +222,15 @@ const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Легенда */}
|
{/* Легенда - горизонтально над графиком */}
|
||||||
<g transform={`translate(${width - margin.right - 120}, ${margin.top})`}>
|
<g transform={`translate(${margin.left + plotWidth / 2 - 120}, 10)`}>
|
||||||
<circle cx="6" cy="6" r="4" fill="#ef4444" stroke="rgb(15, 23, 42)" strokeWidth="2" />
|
<circle cx="6" cy="6" r="4" fill="#ef4444" stroke="rgb(15, 23, 42)" strokeWidth="2" />
|
||||||
<text x="18" y="10" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
<text x="18" y="10" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||||
Критические
|
Критические
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<circle cx="6" cy="24" r="4" fill="#fb923c" stroke="rgb(15, 23, 42)" strokeWidth="2" />
|
<circle cx="126" cy="6" r="4" fill="#fb923c" stroke="rgb(15, 23, 42)" strokeWidth="2" />
|
||||||
<text x="18" y="28" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
<text x="138" y="10" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||||
Предупреждения
|
Предупреждения
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface BarChartProps {
|
|||||||
const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
||||||
const width = 635
|
const width = 635
|
||||||
const height = 280
|
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 plotWidth = width - margin.left - margin.right
|
||||||
const plotHeight = height - margin.top - margin.bottom
|
const plotHeight = height - margin.top - margin.bottom
|
||||||
const baselineY = margin.top + plotHeight
|
const baselineY = margin.top + plotHeight
|
||||||
@@ -240,15 +240,15 @@ const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Легенда */}
|
{/* Легенда - горизонтально над графиком */}
|
||||||
<g transform={`translate(${width - margin.right - 120}, ${margin.top})`}>
|
<g transform={`translate(${margin.left + plotWidth / 2 - 120}, 10)`}>
|
||||||
<rect x="0" y="0" width="12" height="12" fill="#ef4444" rx="2" />
|
<rect x="0" y="0" width="12" height="12" fill="#ef4444" rx="2" />
|
||||||
<text x="18" y="10" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
<text x="18" y="10" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||||
Критические
|
Критические
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<rect x="0" y="18" width="12" height="12" fill="#fb923c" rx="2" />
|
<rect x="120" y="0" width="12" height="12" fill="#fb923c" rx="2" />
|
||||||
<text x="18" y="28" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
<text x="138" y="10" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||||
Предупреждения
|
Предупреждения
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
|
|
||||||
// Find the mesh for this sensor
|
// Find the mesh for this sensor
|
||||||
const allMeshes = importedMeshesRef.current || []
|
const allMeshes = importedMeshesRef.current || []
|
||||||
const sensorMeshes = collectSensorMeshes(allMeshes)
|
const sensorMeshes = collectSensorMeshes(allMeshes, sensorStatusMap)
|
||||||
const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
|
const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
|
||||||
|
|
||||||
if (!targetMesh) {
|
if (!targetMesh) {
|
||||||
@@ -571,7 +571,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allMeshes = importedMeshesRef.current || []
|
const allMeshes = importedMeshesRef.current || []
|
||||||
const sensorMeshes = collectSensorMeshes(allMeshes)
|
const sensorMeshes = collectSensorMeshes(allMeshes, sensorStatusMap)
|
||||||
if (sensorMeshes.length === 0) {
|
if (sensorMeshes.length === 0) {
|
||||||
setAllSensorsOverlayCircles([])
|
setAllSensorsOverlayCircles([])
|
||||||
return
|
return
|
||||||
@@ -609,14 +609,26 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
return
|
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] 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
|
// Найдём датчики которые есть в sensorStatusMap но НЕТ в 3D модели
|
||||||
const sensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean).slice(0, 5)
|
if (sensorStatusMap) {
|
||||||
console.log('[ModelViewer] Sample sensor IDs from meshes:', sensorIds)
|
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) {
|
if (sensorMeshes.length === 0) {
|
||||||
console.warn('[ModelViewer] No sensor meshes found in 3D model!')
|
console.warn('[ModelViewer] No sensor meshes found in 3D model!')
|
||||||
@@ -670,7 +682,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const sensorMeshes = collectSensorMeshes(allMeshes)
|
const sensorMeshes = collectSensorMeshes(allMeshes, sensorStatusMap)
|
||||||
const allSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m))
|
const allSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m))
|
||||||
const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
|
const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ import {
|
|||||||
PointerEventTypes,
|
PointerEventTypes,
|
||||||
PointerInfo,
|
PointerInfo,
|
||||||
Matrix,
|
Matrix,
|
||||||
|
Ray,
|
||||||
} from '@babylonjs/core'
|
} from '@babylonjs/core'
|
||||||
import '@babylonjs/loaders'
|
import '@babylonjs/loaders'
|
||||||
|
|
||||||
import SceneToolbar from './SceneToolbar';
|
import SceneToolbar from './SceneToolbar';
|
||||||
import LoadingSpinner from '../ui/LoadingSpinner'
|
import LoadingSpinner from '../ui/LoadingSpinner'
|
||||||
|
import useNavigationStore from '@/app/store/navigationStore'
|
||||||
import {
|
import {
|
||||||
getSensorIdFromMesh,
|
getSensorIdFromMesh,
|
||||||
collectSensorMeshes,
|
collectSensorMeshes,
|
||||||
@@ -67,6 +69,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
onSensorPick,
|
onSensorPick,
|
||||||
highlightAllSensors,
|
highlightAllSensors,
|
||||||
sensorStatusMap,
|
sensorStatusMap,
|
||||||
|
showStats = false,
|
||||||
|
onToggleStats,
|
||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
const engineRef = useRef<Nullable<Engine>>(null)
|
const engineRef = useRef<Nullable<Engine>>(null)
|
||||||
@@ -339,7 +343,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
|
|
||||||
let engine: Engine
|
let engine: Engine
|
||||||
try {
|
try {
|
||||||
engine = new Engine(canvas, true, { stencil: true })
|
// Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU
|
||||||
|
engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
const message = `WebGL недоступен: ${errorMessage}`
|
const message = `WebGL недоступен: ${errorMessage}`
|
||||||
@@ -361,6 +366,9 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
sceneRef.current = scene
|
sceneRef.current = scene
|
||||||
|
|
||||||
scene.clearColor = new Color4(0.1, 0.1, 0.15, 1)
|
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)
|
const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene)
|
||||||
camera.attachControl(canvas, true)
|
camera.attachControl(canvas, true)
|
||||||
@@ -688,16 +696,65 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
const maxDimension = Math.max(size.x, size.y, size.z)
|
const maxDimension = Math.max(size.x, size.y, size.z)
|
||||||
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
|
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)
|
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()
|
const ease = new CubicEase()
|
||||||
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
||||||
const frameRate = 60
|
const frameRate = 60
|
||||||
const durationMs = 600
|
const durationMs = 800
|
||||||
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
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('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('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(
|
applyHighlightToMeshes(
|
||||||
highlightLayerRef.current,
|
highlightLayerRef.current,
|
||||||
@@ -862,7 +919,10 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
onPan={handlePan}
|
onPan={handlePan}
|
||||||
onSelectModel={onSelectModel}
|
onSelectModel={onSelectModel}
|
||||||
panActive={panActive}
|
panActive={panActive}
|
||||||
|
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
|
||||||
|
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* UPDATED: Interactive overlay circles with hover effects */}
|
{/* UPDATED: Interactive overlay circles with hover effects */}
|
||||||
@@ -908,4 +968,4 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ModelViewer
|
export default React.memo(ModelViewer)
|
||||||
|
|||||||
@@ -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<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
const ModelViewer: React.FC<ModelViewerProps> = ({
|
|
||||||
modelPath,
|
|
||||||
onSelectModel,
|
|
||||||
onModelLoaded,
|
|
||||||
onError,
|
|
||||||
focusSensorId,
|
|
||||||
renderOverlay,
|
|
||||||
isSensorSelectionEnabled,
|
|
||||||
onSensorPick,
|
|
||||||
highlightAllSensors,
|
|
||||||
sensorStatusMap,
|
|
||||||
showStats = false,
|
|
||||||
onToggleStats,
|
|
||||||
}) => {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
||||||
const engineRef = useRef<Nullable<Engine>>(null)
|
|
||||||
const sceneRef = useRef<Nullable<Scene>>(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<AbstractMesh[]>([])
|
|
||||||
const highlightLayerRef = useRef<HighlightLayer | null>(null)
|
|
||||||
const highlightedMeshesRef = useRef<AbstractMesh[]>([])
|
|
||||||
const chosenMeshRef = useRef<AbstractMesh | null>(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<string | null>(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<string | null>(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 (
|
|
||||||
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
|
||||||
{!modelPath ? (
|
|
||||||
<div className="h-full flex items-center justify-center">
|
|
||||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
|
||||||
<div className="text-amber-400 text-lg font-semibold mb-2">
|
|
||||||
3D модель не выбрана
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-300 mb-4">
|
|
||||||
Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className={`w-full h-full outline-none block transition-opacity duration-500 ${
|
|
||||||
showModel && !webglError ? 'opacity-100' : 'opacity-0'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{webglError ? (
|
|
||||||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
|
||||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
|
||||||
<div className="text-red-400 text-lg font-semibold mb-2">
|
|
||||||
3D просмотр недоступен
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-300 mb-4">
|
|
||||||
{webglError}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : isLoading ? (
|
|
||||||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
|
||||||
<LoadingSpinner
|
|
||||||
progress={loadingProgress}
|
|
||||||
size={120}
|
|
||||||
strokeWidth={8}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : !modelReady ? (
|
|
||||||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-40">
|
|
||||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
|
||||||
<div className="text-gray-400 text-lg font-semibold mb-4">
|
|
||||||
3D модель не загружена
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
Модель не готова к отображению
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<SceneToolbar
|
|
||||||
onZoomIn={handleZoomIn}
|
|
||||||
onZoomOut={handleZoomOut}
|
|
||||||
onTopView={handleTopView}
|
|
||||||
onPan={handlePan}
|
|
||||||
onSelectModel={onSelectModel}
|
|
||||||
panActive={panActive}
|
|
||||||
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
|
|
||||||
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{/* 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 (
|
|
||||||
<div
|
|
||||||
key={`${circle.sensorId}-${Math.round(circle.left)}-${Math.round(circle.top)}`}
|
|
||||||
onClick={() => 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
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ModelViewer
|
|
||||||
@@ -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<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
const ModelViewer: React.FC<ModelViewerProps> = ({
|
|
||||||
modelPath,
|
|
||||||
onSelectModel,
|
|
||||||
onModelLoaded,
|
|
||||||
onError,
|
|
||||||
focusSensorId,
|
|
||||||
renderOverlay,
|
|
||||||
isSensorSelectionEnabled,
|
|
||||||
onSensorPick,
|
|
||||||
highlightAllSensors,
|
|
||||||
sensorStatusMap,
|
|
||||||
showStats = false,
|
|
||||||
onToggleStats,
|
|
||||||
}) => {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
||||||
const engineRef = useRef<Nullable<Engine>>(null)
|
|
||||||
const sceneRef = useRef<Nullable<Scene>>(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<AbstractMesh[]>([])
|
|
||||||
const highlightLayerRef = useRef<HighlightLayer | null>(null)
|
|
||||||
const highlightedMeshesRef = useRef<AbstractMesh[]>([])
|
|
||||||
const chosenMeshRef = useRef<AbstractMesh | null>(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<string | null>(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<string | null>(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 (
|
|
||||||
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
|
||||||
{!modelPath ? (
|
|
||||||
<div className="h-full flex items-center justify-center">
|
|
||||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
|
||||||
<div className="text-amber-400 text-lg font-semibold mb-2">
|
|
||||||
3D модель не выбрана
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-300 mb-4">
|
|
||||||
Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className={`w-full h-full outline-none block transition-opacity duration-500 ${
|
|
||||||
showModel && !webglError ? 'opacity-100' : 'opacity-0'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{webglError ? (
|
|
||||||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
|
||||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
|
||||||
<div className="text-red-400 text-lg font-semibold mb-2">
|
|
||||||
3D просмотр недоступен
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-300 mb-4">
|
|
||||||
{webglError}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : isLoading ? (
|
|
||||||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
|
||||||
<LoadingSpinner
|
|
||||||
progress={loadingProgress}
|
|
||||||
size={120}
|
|
||||||
strokeWidth={8}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : !modelReady ? (
|
|
||||||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-40">
|
|
||||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
|
||||||
<div className="text-gray-400 text-lg font-semibold mb-4">
|
|
||||||
3D модель не загружена
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
Модель не готова к отображению
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<SceneToolbar
|
|
||||||
onZoomIn={handleZoomIn}
|
|
||||||
onZoomOut={handleZoomOut}
|
|
||||||
onTopView={handleTopView}
|
|
||||||
onPan={handlePan}
|
|
||||||
onSelectModel={onSelectModel}
|
|
||||||
panActive={panActive}
|
|
||||||
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
|
|
||||||
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{/* 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 (
|
|
||||||
<div
|
|
||||||
key={`${circle.sensorId}-${Math.round(circle.left)}-${Math.round(circle.top)}`}
|
|
||||||
onClick={() => 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
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ModelViewer
|
|
||||||
@@ -59,11 +59,24 @@ export const getSensorIdFromMesh = (m: AbstractMesh | null): string | null => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const collectSensorMeshes = (meshes: AbstractMesh[]): AbstractMesh[] => {
|
export const collectSensorMeshes = (
|
||||||
|
meshes: AbstractMesh[],
|
||||||
|
sensorStatusMap?: Record<string, string> | null
|
||||||
|
): AbstractMesh[] => {
|
||||||
const result: AbstractMesh[] = []
|
const result: AbstractMesh[] = []
|
||||||
for (const m of meshes) {
|
for (const m of meshes) {
|
||||||
const sid = getSensorIdFromMesh(m)
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
96
frontend/components/model/sensorHighlight — копия.ts
Normal file
96
frontend/components/model/sensorHighlight — копия.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -71,14 +71,18 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
|
|
||||||
// Группируем уведомления по дням за последний месяц
|
// Группируем уведомления по дням за последний месяц
|
||||||
const now = new Date()
|
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 }
|
// Создаём карту: дата -> { critical: count, warning: count }
|
||||||
const dayMap: Record<string, { critical: number; warning: number }> = {}
|
const dayMap: Record<string, { critical: number; warning: number }> = {}
|
||||||
|
|
||||||
// Инициализируем все дни нулями
|
// Инициализируем все дни нулями (включая текущий день)
|
||||||
for (let i = 0; i < DAYS_COUNT; i++) {
|
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]
|
const dateKey = date.toISOString().split('T')[0]
|
||||||
dayMap[dateKey] = { critical: 0, warning: 0 }
|
dayMap[dateKey] = { critical: 0, warning: 0 }
|
||||||
}
|
}
|
||||||
@@ -91,7 +95,9 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const notifDate = new Date(notification.timestamp)
|
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]
|
const dateKey = notifDate.toISOString().split('T')[0]
|
||||||
if (dayMap[dateKey]) {
|
if (dayMap[dateKey]) {
|
||||||
const notifType = String(notification.type || '').toLowerCase()
|
const notifType = String(notification.type || '').toLowerCase()
|
||||||
@@ -121,6 +127,8 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
|
|
||||||
// Определение типа детектора и его отображаемого названия
|
// Определение типа детектора и его отображаемого названия
|
||||||
const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase()
|
const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase()
|
||||||
|
|
||||||
|
// Извлекаем код из текстового поля type
|
||||||
const deriveCodeFromType = (): string => {
|
const deriveCodeFromType = (): string => {
|
||||||
const t = (detector.type || '').toLowerCase()
|
const t = (detector.type || '').toLowerCase()
|
||||||
if (!t) return ''
|
if (!t) return ''
|
||||||
@@ -129,7 +137,15 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
if (t.includes('гидроуров')) return 'GLE'
|
if (t.includes('гидроуров')) return 'GLE'
|
||||||
return ''
|
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<string, string> = {
|
const detectorTypeLabelMap: Record<string, string> = {
|
||||||
@@ -137,6 +153,15 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
PE: 'Тензометр',
|
PE: 'Тензометр',
|
||||||
GLE: 'Гидроуровень',
|
GLE: 'Гидроуровень',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Определяем эффективный код типа датчика с fallback
|
||||||
|
let effectiveDetectorTypeCode = rawDetectorTypeCode
|
||||||
|
|
||||||
|
// Если rawDetectorTypeCode не найден в карте - используем fallback
|
||||||
|
if (!detectorTypeLabelMap[effectiveDetectorTypeCode]) {
|
||||||
|
effectiveDetectorTypeCode = deriveCodeFromType() || deriveCodeFromSerialNumber()
|
||||||
|
}
|
||||||
|
|
||||||
const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—'
|
const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—'
|
||||||
|
|
||||||
// Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором
|
// Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором
|
||||||
|
|||||||
@@ -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<DetectorMenuProps> = ({ 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<string, string> = {
|
|
||||||
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 }) => (
|
|
||||||
<div className={compact ? 'mt-2 space-y-1' : 'space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden'}>
|
|
||||||
{compact ? (
|
|
||||||
// Компактный режим: 4 строки по 2 колонки с основной информацией
|
|
||||||
<>
|
|
||||||
{/* Строка 1: Маркировка и тип детектора */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Маркировка по проекту</div>
|
|
||||||
<div className="text-white text-xs truncate">{detector.name}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Тип детектора</div>
|
|
||||||
<div className="text-white text-xs truncate">{displayDetectorTypeLabel}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Строка 2: Местоположение и статус */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
|
|
||||||
<div className="text-white text-xs truncate">{detector.location}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Статус</div>
|
|
||||||
<div className="text-white text-xs truncate">{getStatusText(detector.status)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Строка 3: Временная метка и этаж */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
|
|
||||||
<div className="text-white text-xs truncate">{formattedTimestamp}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Этаж</div>
|
|
||||||
<div className="text-white text-xs truncate">{detector.floor}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Строка 4: Серийный номер */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Серийный номер</div>
|
|
||||||
<div className="text-white text-xs truncate">{detector.serial_number}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// Полный режим: 3 строки по 2 колонки с рамками между элементами
|
|
||||||
<>
|
|
||||||
{/* Строка 1: Маркировка по проекту и тип детектора */}
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
|
|
||||||
<div className="text-white text-sm">{detector.name}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-4">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Тип детектора</div>
|
|
||||||
<div className="text-white text-sm">{displayDetectorTypeLabel}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Строка 2: Местоположение и статус */}
|
|
||||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
|
||||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
|
|
||||||
<div className="text-white text-sm">{detector.location}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-4">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Статус</div>
|
|
||||||
<div className="text-white text-sm">{getStatusText(detector.status)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Строка 3: Временная метка и серийный номер */}
|
|
||||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
|
||||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Временная метка</div>
|
|
||||||
<div className="text-white text-sm">{formattedTimestamp}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-4">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Серийный номер</div>
|
|
||||||
<div className="text-white text-sm">{detector.serial_number}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Компактный режим с якорной позицией (всплывающее окно)
|
|
||||||
// Используется для отображения информации при наведении на детектор в списке
|
|
||||||
if (compact && anchor) {
|
|
||||||
return (
|
|
||||||
<div className="absolute z-40" style={{ left: anchor.left, top: anchor.top }}>
|
|
||||||
<div className="rounded-[10px] bg-black/80 text-white text-xs px-3 py-2 shadow-xl min-w-[300px] max-w-[400px]">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-semibold truncate">{detector.name}</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={onClose} className="text-gray-300 hover:text-white transition-colors">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
|
||||||
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
Отчет
|
|
||||||
</button>
|
|
||||||
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
История
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<DetailsSection compact={true} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Полный режим боковой панели (основной режим)
|
|
||||||
// Отображается как правая панель с полной информацией о детекторе
|
|
||||||
return (
|
|
||||||
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 з-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
|
||||||
<div className="h-full overflow-auto p-5">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
{/* Заголовок с названием детектора */}
|
|
||||||
<h3 className="text-white text-lg font-medium">
|
|
||||||
{detector.name}
|
|
||||||
</h3>
|
|
||||||
{/* Кнопки действий: Отчет и История */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
Отчет
|
|
||||||
</button>
|
|
||||||
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
История
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Секция с детальной информацией о детекторе */}
|
|
||||||
<DetailsSection />
|
|
||||||
|
|
||||||
{/* Кнопка закрытия панели */}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6л12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DetectorMenu
|
|
||||||
@@ -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<DetectorMenuProps> = ({ 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<string, string> = {
|
|
||||||
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 }) => (
|
|
||||||
<div className={compact ? 'mt-2 space-y-1' : 'space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden'}>
|
|
||||||
{compact ? (
|
|
||||||
// Компактный режим: 4 строки по 2 колонки с основной информацией
|
|
||||||
<>
|
|
||||||
{/* Строка 1: Маркировка и тип детектора */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Маркировка по проекту</div>
|
|
||||||
<div className="text-white text-xs truncate">{detector.name}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Тип детектора</div>
|
|
||||||
<div className="text-white text-xs truncate">{displayDetectorTypeLabel}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Строка 2: Местоположение и статус */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
|
|
||||||
<div className="text-white text-xs truncate">{detector.location}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Статус</div>
|
|
||||||
<div className="text-white text-xs truncate">{getStatusText(detector.status)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Строка 3: Временная метка и этаж */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
|
|
||||||
<div className="text-white text-xs truncate">{formattedTimestamp}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Этаж</div>
|
|
||||||
<div className="text-white text-xs truncate">{detector.floor}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Строка 4: Серийный номер */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Серийный номер</div>
|
|
||||||
<div className="text-white text-xs truncate">{detector.serial_number}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// Полный режим: 3 строки по 2 колонки с рамками между элементами
|
|
||||||
<>
|
|
||||||
{/* Строка 1: Маркировка по проекту и тип детектора */}
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
|
|
||||||
<div className="text-white text-sm">{detector.name}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-4">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Тип детектора</div>
|
|
||||||
<div className="text-white text-sm">{displayDetectorTypeLabel}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Строка 2: Местоположение и статус */}
|
|
||||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
|
||||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
|
|
||||||
<div className="text-white text-sm">{detector.location}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-4">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Статус</div>
|
|
||||||
<div className="text-white text-sm">{getStatusText(detector.status)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Строка 3: Временная метка и серийный номер */}
|
|
||||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
|
||||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Временная метка</div>
|
|
||||||
<div className="text-white text-sm">{formattedTimestamp}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-4">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Серийный номер</div>
|
|
||||||
<div className="text-white text-sm">{detector.serial_number}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Компактный режим с якорной позицией (всплывающее окно)
|
|
||||||
// Используется для отображения информации при наведении на детектор в списке
|
|
||||||
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 (
|
|
||||||
<div className="absolute z-40" style={{ left: anchor.left, top: adjustedTop }}>
|
|
||||||
<div className="rounded-[10px] bg-black/80 text-white text-xs px-3 py-2 shadow-xl min-w-[300px] max-w-[400px]">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-semibold truncate">{detector.name}</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={onClose} className="text-gray-300 hover:text-white transition-colors">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
|
||||||
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
Отчет
|
|
||||||
</button>
|
|
||||||
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
История
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<DetailsSection compact={true} />
|
|
||||||
|
|
||||||
{/* График за последние 3 дня */}
|
|
||||||
<div className="mt-3">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px] mb-2">График за 3 дня</div>
|
|
||||||
<div className="min-h-[100px]">
|
|
||||||
<AreaChart data={chartData} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Полный режим боковой панели (основной режим)
|
|
||||||
// Отображается как правая панель с полной информацией о детекторе
|
|
||||||
return (
|
|
||||||
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 з-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
|
||||||
<div className="h-full overflow-auto p-5">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
{/* Заголовок с названием детектора */}
|
|
||||||
<h3 className="text-white text-lg font-medium">
|
|
||||||
{detector.name}
|
|
||||||
</h3>
|
|
||||||
{/* Кнопки действий: Отчет и История */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
Отчет
|
|
||||||
</button>
|
|
||||||
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
История
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Секция с детальной информацией о детекторе */}
|
|
||||||
<DetailsSection />
|
|
||||||
|
|
||||||
{/* График за последние 3 дня */}
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-white text-base font-medium mb-4">График за последние 3 дня</h4>
|
|
||||||
<div className="min-h-[200px] bg-[rgb(22,24,36)] rounded-lg p-4">
|
|
||||||
<AreaChart data={chartData} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Кнопка закрытия панели */}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6л12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DetectorMenu
|
|
||||||
@@ -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<DetectorMenuProps> = ({ 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<string, string> = {
|
|
||||||
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 }) => (
|
|
||||||
<div className={compact ? 'mt-2 space-y-1' : 'space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden'}>
|
|
||||||
{compact ? (
|
|
||||||
// Компактный режим: 4 строки по 2 колонки с основной информацией
|
|
||||||
<>
|
|
||||||
{/* Строка 1: Маркировка и тип детектора */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Маркировка по проекту</div>
|
|
||||||
<div className="text-white text-xs truncate">{detector.name}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Тип детектора</div>
|
|
||||||
<div className="text-white text-xs truncate">{displayDetectorTypeLabel}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Строка 2: Местоположение и статус */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
|
|
||||||
<div className="text-white text-xs truncate">{detector.location}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Статус</div>
|
|
||||||
<div className="text-white text-xs truncate">{getStatusText(detector.status)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Строка 3: Временная метка и этаж */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
|
|
||||||
<div className="text-white text-xs truncate">{formattedTimestamp}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Этаж</div>
|
|
||||||
<div className="text-white text-xs truncate">{detector.floor}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Строка 4: Серийный номер */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px]">Серийный номер</div>
|
|
||||||
<div className="text-white text-xs truncate">{detector.serial_number}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// Полный режим: 3 строки по 2 колонки с рамками между элементами
|
|
||||||
<>
|
|
||||||
{/* Строка 1: Маркировка по проекту и тип детектора */}
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
|
|
||||||
<div className="text-white text-sm">{detector.name}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-4">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Тип детектора</div>
|
|
||||||
<div className="text-white text-sm">{displayDetectorTypeLabel}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Строка 2: Местоположение и статус */}
|
|
||||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
|
||||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
|
|
||||||
<div className="text-white text-sm">{detector.location}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-4">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Статус</div>
|
|
||||||
<div className="text-white text-sm">{getStatusText(detector.status)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Строка 3: Временная метка и серийный номер */}
|
|
||||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
|
||||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Временная метка</div>
|
|
||||||
<div className="text-white text-sm">{formattedTimestamp}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-4">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Серийный номер</div>
|
|
||||||
<div className="text-white text-sm">{detector.serial_number}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Компактный режим с якорной позицией (всплывающее окно)
|
|
||||||
// Используется для отображения информации при наведении на детектор в списке
|
|
||||||
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 (
|
|
||||||
<div className="absolute z-40" style={{ left: anchor.left, top: adjustedTop }}>
|
|
||||||
<div className="rounded-[10px] bg-black/80 text-white text-xs px-3 py-2 shadow-xl min-w-[300px] max-w-[400px]">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-semibold truncate">{detector.name}</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={onClose} className="text-gray-300 hover:text-white transition-colors">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
|
||||||
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
Отчет
|
|
||||||
</button>
|
|
||||||
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
История
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<DetailsSection compact={true} />
|
|
||||||
|
|
||||||
{/* График за последние 3 дня */}
|
|
||||||
<div className="mt-3">
|
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px] mb-2">График за 3 дня</div>
|
|
||||||
<div className="min-h-[100px]">
|
|
||||||
<AreaChart data={chartData} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Полный режим боковой панели (основной режим)
|
|
||||||
// Отображается как правая панель с полной информацией о детекторе
|
|
||||||
return (
|
|
||||||
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 з-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
|
||||||
<div className="h-full overflow-auto p-5">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
{/* Заголовок с названием детектора */}
|
|
||||||
<h3 className="text-white text-lg font-medium">
|
|
||||||
{detector.name}
|
|
||||||
</h3>
|
|
||||||
{/* Кнопки действий: Отчет и История */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
Отчет
|
|
||||||
</button>
|
|
||||||
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
История
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Секция с детальной информацией о детекторе */}
|
|
||||||
<DetailsSection />
|
|
||||||
|
|
||||||
{/* График за последние 3 дня */}
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="text-white text-base font-medium mb-4">График за последние 3 дня</h4>
|
|
||||||
<div className="min-h-[200px] bg-[rgb(22,24,36)] rounded-lg p-4">
|
|
||||||
<AreaChart data={chartData} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Кнопка закрытия панели */}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6л12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DetectorMenu
|
|
||||||
Reference in New Issue
Block a user