Compare commits
12 Commits
AEB-71/aer
...
aerbim-ht-
| Author | SHA1 | Date | |
|---|---|---|---|
| c6bc5d19f2 | |||
| e50e525ad4 | |||
| b2f828a580 | |||
| 41a7bef1cd | |||
| 019a260021 | |||
| bad3b63911 | |||
| 6caf1c9dbb | |||
| 44473a8d9d | |||
| f275db88c9 | |||
| 79e4845870 | |||
| 5e58f6ef76 | |||
| 458222817e |
@@ -66,8 +66,9 @@ class DetectorSerializer(serializers.ModelSerializer):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def get_status(self, obj):
|
def get_status(self, obj):
|
||||||
# проверяем наличие активных алертов
|
# Проверяем наличие нерешённых алертов
|
||||||
latest_alert = obj.alerts.filter(resolved=False).first()
|
# Берём самый свежий (последний по времени) нерешённый алерт
|
||||||
|
latest_alert = obj.alerts.filter(resolved=False).order_by('-created_at').first()
|
||||||
if latest_alert:
|
if latest_alert:
|
||||||
return latest_alert.severity # вернет 'warning' или 'critical'
|
return latest_alert.severity # вернет 'warning' или 'critical'
|
||||||
return 'normal'
|
return 'normal'
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from drf_spectacular.utils import extend_schema_field
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from typing import Dict, Any
|
||||||
|
from sitemanagement.models import Sensor, Alert
|
||||||
|
|
||||||
|
class NotificationSerializer(serializers.ModelSerializer):
|
||||||
|
type = serializers.SerializerMethodField()
|
||||||
|
priority = serializers.SerializerMethodField()
|
||||||
|
timestamp = serializers.DateTimeField(source='created_at')
|
||||||
|
acknowledged = serializers.BooleanField(source='resolved', default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Alert
|
||||||
|
fields = ('id', 'type', 'timestamp', 'acknowledged', 'priority')
|
||||||
|
|
||||||
|
def get_type(self, obj):
|
||||||
|
return 'critical' if obj.severity == 'critical' else 'warning'
|
||||||
|
|
||||||
|
def get_priority(self, obj):
|
||||||
|
return 'high' if obj.severity == 'critical' else 'medium'
|
||||||
|
|
||||||
|
class DetectorSerializer(serializers.ModelSerializer):
|
||||||
|
detector_id = serializers.SerializerMethodField()
|
||||||
|
type = serializers.SerializerMethodField()
|
||||||
|
detector_type = serializers.SerializerMethodField()
|
||||||
|
name = serializers.SerializerMethodField()
|
||||||
|
object = serializers.SerializerMethodField()
|
||||||
|
status = serializers.SerializerMethodField()
|
||||||
|
zone = serializers.SerializerMethodField()
|
||||||
|
floor = serializers.SerializerMethodField()
|
||||||
|
notifications = NotificationSerializer(source='alerts', many=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Sensor
|
||||||
|
fields = ('detector_id', 'type', 'detector_type', 'serial_number', 'name', 'object', 'status', 'zone', 'floor', 'notifications')
|
||||||
|
|
||||||
|
def get_detector_id(self, obj):
|
||||||
|
# Используем serial_number для совместимости с 3D моделью
|
||||||
|
# Если serial_number нет, используем ID с префиксом
|
||||||
|
return obj.serial_number or f"sensor_{obj.id}"
|
||||||
|
|
||||||
|
def get_type(self, obj):
|
||||||
|
sensor_type_mapping = {
|
||||||
|
'GA': 'Инклинометр',
|
||||||
|
'PE': 'Тензометр',
|
||||||
|
'GLE': 'Гидроуровень',
|
||||||
|
}
|
||||||
|
code = (getattr(obj.sensor_type, 'code', '') or '').upper()
|
||||||
|
return sensor_type_mapping.get(code, (getattr(obj.sensor_type, 'name', '') or ''))
|
||||||
|
|
||||||
|
def get_detector_type(self, obj):
|
||||||
|
return (getattr(obj.sensor_type, 'code', '') or '').upper()
|
||||||
|
|
||||||
|
def get_name(self, obj):
|
||||||
|
sensor_type = getattr(obj, 'sensor_type', None) or getattr(obj, 'sensor_type', None)
|
||||||
|
serial = getattr(obj, 'serial_number', '') or ''
|
||||||
|
base_name = getattr(obj, 'name', '') or ''
|
||||||
|
return base_name or f"{getattr(obj.sensor_type, 'code', '')}-{serial}".strip('-')
|
||||||
|
|
||||||
|
def get_object(self, obj):
|
||||||
|
# получаем первую зону датчика и её объект
|
||||||
|
zone = obj.zones.first()
|
||||||
|
if zone:
|
||||||
|
return zone.object.title
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_status(self, obj):
|
||||||
|
# проверяем наличие активных алертов
|
||||||
|
latest_alert = obj.alerts.filter(resolved=False).first()
|
||||||
|
if latest_alert:
|
||||||
|
return latest_alert.severity # вернет 'warning' или 'critical'
|
||||||
|
return 'normal'
|
||||||
|
|
||||||
|
def get_zone(self, obj):
|
||||||
|
first_zone = obj.zones.first()
|
||||||
|
return first_zone.name if first_zone else None
|
||||||
|
|
||||||
|
def get_floor(self, obj):
|
||||||
|
first_zone = obj.zones.first()
|
||||||
|
return getattr(first_zone, 'floor', None)
|
||||||
|
|
||||||
|
class DetectorsResponseSerializer(serializers.Serializer):
|
||||||
|
detectors = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(OpenApiTypes.OBJECT)
|
||||||
|
def get_detectors(self, sensors) -> Dict[str, Any]:
|
||||||
|
detector_serializer = DetectorSerializer(sensors, many=True)
|
||||||
|
return {
|
||||||
|
sensor['detector_id']: sensor
|
||||||
|
for sensor in detector_serializer.data
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
|
||||||
@@ -115,10 +115,17 @@ const NavigationPage: React.FC = () => {
|
|||||||
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
||||||
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
||||||
const sensorStatusMap = React.useMemo(() => {
|
const sensorStatusMap = React.useMemo(() => {
|
||||||
|
// Создаём карту статусов всегда для отображения цветов датчиков
|
||||||
const map: Record<string, string> = {}
|
const map: Record<string, string> = {}
|
||||||
Object.values(detectorsData.detectors).forEach(d => {
|
Object.values(detectorsData.detectors).forEach(d => {
|
||||||
if (d.serial_number && d.status) {
|
if (d.serial_number && d.status) {
|
||||||
map[String(d.serial_number).trim()] = d.status
|
map[String(d.serial_number).trim()] = d.status
|
||||||
|
|
||||||
|
// Логируем GLE-1
|
||||||
|
if (d.serial_number === 'GLE-1') {
|
||||||
|
console.log('[NavigationPage] GLE-1 status from API:', d.status)
|
||||||
|
console.log('[NavigationPage] GLE-1 notifications:', d.notifications)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors')
|
console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors')
|
||||||
@@ -163,9 +170,11 @@ const NavigationPage: React.FC = () => {
|
|||||||
const urlObjectId = searchParams.get('objectId')
|
const urlObjectId = searchParams.get('objectId')
|
||||||
const urlObjectTitle = searchParams.get('objectTitle')
|
const urlObjectTitle = searchParams.get('objectTitle')
|
||||||
const urlModelPath = searchParams.get('modelPath')
|
const urlModelPath = searchParams.get('modelPath')
|
||||||
|
const urlFocusSensorId = searchParams.get('focusSensorId')
|
||||||
const objectId = currentObject.id || urlObjectId
|
const objectId = currentObject.id || urlObjectId
|
||||||
const objectTitle = currentObject.title || urlObjectTitle
|
const objectTitle = currentObject.title || urlObjectTitle
|
||||||
const [selectedModelPath, setSelectedModelPath] = useState<string>(urlModelPath || '')
|
const [selectedModelPath, setSelectedModelPath] = useState<string>('')
|
||||||
|
|
||||||
|
|
||||||
const handleModelLoaded = useCallback(() => {
|
const handleModelLoaded = useCallback(() => {
|
||||||
setIsModelReady(true)
|
setIsModelReady(true)
|
||||||
@@ -182,12 +191,8 @@ const NavigationPage: React.FC = () => {
|
|||||||
if (selectedModelPath) {
|
if (selectedModelPath) {
|
||||||
setIsModelReady(false);
|
setIsModelReady(false);
|
||||||
setModelError(null);
|
setModelError(null);
|
||||||
// Сохраняем выбранную модель в URL для восстановления при возврате
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
|
||||||
params.set('modelPath', selectedModelPath);
|
|
||||||
window.history.replaceState(null, '', `?${params.toString()}`);
|
|
||||||
}
|
}
|
||||||
}, [selectedModelPath, searchParams]);
|
}, [selectedModelPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
||||||
@@ -195,18 +200,85 @@ const NavigationPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject])
|
}, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject])
|
||||||
|
|
||||||
// Восстановление выбранной модели из URL при загрузке страницы
|
// Восстановление выбранной модели из URL при загрузке страницы (только если переход с focusSensorId)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (urlModelPath && !selectedModelPath) {
|
// Восстанавливаем modelPath только если есть focusSensorId (переход к конкретному датчику)
|
||||||
|
if (urlModelPath && urlFocusSensorId && !selectedModelPath) {
|
||||||
setSelectedModelPath(urlModelPath);
|
setSelectedModelPath(urlModelPath);
|
||||||
}
|
}
|
||||||
}, [urlModelPath, selectedModelPath])
|
}, [urlModelPath, urlFocusSensorId, selectedModelPath])
|
||||||
|
|
||||||
|
// Автоматическая загрузка модели с order=0 при пустом selectedModelPath
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDefaultModel = async () => {
|
||||||
|
// Если модель уже выбрана или нет objectId - пропускаем
|
||||||
|
if (selectedModelPath || !objectId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[NavigationPage] Auto-loading model with order=0 for object:', objectId);
|
||||||
|
const response = await fetch(`/api/get-zones?object_id=${objectId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && Array.isArray(data.data)) {
|
||||||
|
// Сортируем по order и берём первую
|
||||||
|
const sorted = data.data.slice().sort((a: any, b: any) => {
|
||||||
|
const oa = typeof a.order === 'number' ? a.order : 0;
|
||||||
|
const ob = typeof b.order === 'number' ? b.order : 0;
|
||||||
|
return oa - ob;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sorted.length > 0 && sorted[0].model_path) {
|
||||||
|
console.log('[NavigationPage] Auto-selected model with order=0:', sorted[0].model_path);
|
||||||
|
setSelectedModelPath(sorted[0].model_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[NavigationPage] Failed to load default model:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDefaultModel();
|
||||||
|
}, [selectedModelPath, objectId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
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 }
|
||||||
@@ -223,7 +295,7 @@ const NavigationPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadDetectors()
|
loadDetectors()
|
||||||
}, [])
|
}, [selectedModelPath, objectId])
|
||||||
|
|
||||||
const handleBackClick = () => {
|
const handleBackClick = () => {
|
||||||
router.push('/dashboard')
|
router.push('/dashboard')
|
||||||
@@ -345,27 +417,8 @@ const NavigationPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (detector) {
|
if (detector) {
|
||||||
if (showFloorNavigation || showListOfDetectors) {
|
// Всегда показываем меню детектора для всех датчиков
|
||||||
handleDetectorMenuClick(detector);
|
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 {
|
} else {
|
||||||
setFocusedSensorId(null);
|
setFocusedSensorId(null);
|
||||||
closeDetectorMenu();
|
closeDetectorMenu();
|
||||||
@@ -377,6 +430,25 @@ const NavigationPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Обработка 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 getStatusText = (status: string) => {
|
||||||
const s = (status || '').toLowerCase()
|
const s = (status || '').toLowerCase()
|
||||||
switch (s) {
|
switch (s) {
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ const NavigationPage: React.FC = () => {
|
|||||||
setSelectedNotification,
|
setSelectedNotification,
|
||||||
setShowNotificationDetectorInfo,
|
setShowNotificationDetectorInfo,
|
||||||
setSelectedAlert,
|
setSelectedAlert,
|
||||||
setShowAlertMenu
|
setShowAlertMenu,
|
||||||
|
showSensorHighlights,
|
||||||
|
toggleSensorHighlights
|
||||||
} = useNavigationStore()
|
} = useNavigationStore()
|
||||||
|
|
||||||
const [detectorsData, setDetectorsData] = useState<{ detectors: Record<string, DetectorType> }>({ detectors: {} })
|
const [detectorsData, setDetectorsData] = useState<{ detectors: Record<string, DetectorType> }>({ detectors: {} })
|
||||||
@@ -113,10 +115,17 @@ const NavigationPage: React.FC = () => {
|
|||||||
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
||||||
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
||||||
const sensorStatusMap = React.useMemo(() => {
|
const sensorStatusMap = React.useMemo(() => {
|
||||||
|
// Создаём карту статусов всегда для отображения цветов датчиков
|
||||||
const map: Record<string, string> = {}
|
const map: Record<string, string> = {}
|
||||||
Object.values(detectorsData.detectors).forEach(d => {
|
Object.values(detectorsData.detectors).forEach(d => {
|
||||||
if (d.serial_number && d.status) {
|
if (d.serial_number && d.status) {
|
||||||
map[String(d.serial_number).trim()] = d.status
|
map[String(d.serial_number).trim()] = d.status
|
||||||
|
|
||||||
|
// Логируем GLE-1
|
||||||
|
if (d.serial_number === 'GLE-1') {
|
||||||
|
console.log('[NavigationPage] GLE-1 status from API:', d.status)
|
||||||
|
console.log('[NavigationPage] GLE-1 notifications:', d.notifications)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors')
|
console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors')
|
||||||
@@ -161,10 +170,12 @@ const NavigationPage: React.FC = () => {
|
|||||||
const urlObjectId = searchParams.get('objectId')
|
const urlObjectId = searchParams.get('objectId')
|
||||||
const urlObjectTitle = searchParams.get('objectTitle')
|
const urlObjectTitle = searchParams.get('objectTitle')
|
||||||
const urlModelPath = searchParams.get('modelPath')
|
const urlModelPath = searchParams.get('modelPath')
|
||||||
|
const urlFocusSensorId = searchParams.get('focusSensorId')
|
||||||
const objectId = currentObject.id || urlObjectId
|
const objectId = currentObject.id || urlObjectId
|
||||||
const objectTitle = currentObject.title || urlObjectTitle
|
const objectTitle = currentObject.title || urlObjectTitle
|
||||||
const [selectedModelPath, setSelectedModelPath] = useState<string>(urlModelPath || '')
|
const [selectedModelPath, setSelectedModelPath] = useState<string>(urlModelPath || '')
|
||||||
|
|
||||||
|
|
||||||
const handleModelLoaded = useCallback(() => {
|
const handleModelLoaded = useCallback(() => {
|
||||||
setIsModelReady(true)
|
setIsModelReady(true)
|
||||||
setModelError(null)
|
setModelError(null)
|
||||||
@@ -204,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 }
|
||||||
@@ -221,7 +266,7 @@ const NavigationPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadDetectors()
|
loadDetectors()
|
||||||
}, [])
|
}, [selectedModelPath, objectId])
|
||||||
|
|
||||||
const handleBackClick = () => {
|
const handleBackClick = () => {
|
||||||
router.push('/dashboard')
|
router.push('/dashboard')
|
||||||
@@ -343,27 +388,8 @@ const NavigationPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (detector) {
|
if (detector) {
|
||||||
if (showFloorNavigation || showListOfDetectors) {
|
// Всегда показываем меню детектора для всех датчиков
|
||||||
handleDetectorMenuClick(detector);
|
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 {
|
} else {
|
||||||
setFocusedSensorId(null);
|
setFocusedSensorId(null);
|
||||||
closeDetectorMenu();
|
closeDetectorMenu();
|
||||||
@@ -375,6 +401,25 @@ const NavigationPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Обработка 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 getStatusText = (status: string) => {
|
||||||
const s = (status || '').toLowerCase()
|
const s = (status || '').toLowerCase()
|
||||||
switch (s) {
|
switch (s) {
|
||||||
@@ -574,7 +619,7 @@ const NavigationPage: React.FC = () => {
|
|||||||
onError={handleModelError}
|
onError={handleModelError}
|
||||||
activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null}
|
activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null}
|
||||||
focusSensorId={focusedSensorId}
|
focusSensorId={focusedSensorId}
|
||||||
highlightAllSensors={highlightAllSensors}
|
highlightAllSensors={showSensorHighlights && highlightAllSensors}
|
||||||
sensorStatusMap={sensorStatusMap}
|
sensorStatusMap={sensorStatusMap}
|
||||||
isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors}
|
isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors}
|
||||||
onSensorPick={handleSensorSelection}
|
onSensorPick={handleSensorSelection}
|
||||||
|
|||||||
687
frontend/app/(protected)/navigation/page.tsx — копия 3
Normal file
687
frontend/app/(protected)/navigation/page.tsx — копия 3
Normal file
@@ -0,0 +1,687 @@
|
|||||||
|
'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
|
||||||
|
|
||||||
|
// Логируем GLE-1
|
||||||
|
if (d.serial_number === 'GLE-1') {
|
||||||
|
console.log('[NavigationPage] GLE-1 status from API:', d.status)
|
||||||
|
console.log('[NavigationPage] GLE-1 notifications:', d.notifications)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
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>('')
|
||||||
|
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}, [selectedModelPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
||||||
|
setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined)
|
||||||
|
}
|
||||||
|
}, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject])
|
||||||
|
|
||||||
|
// Восстановление выбранной модели из URL при загрузке страницы (только если переход с focusSensorId)
|
||||||
|
useEffect(() => {
|
||||||
|
// Восстанавливаем modelPath только если есть focusSensorId (переход к конкретному датчику)
|
||||||
|
if (urlModelPath && urlFocusSensorId && !selectedModelPath) {
|
||||||
|
setSelectedModelPath(urlModelPath);
|
||||||
|
}
|
||||||
|
}, [urlModelPath, urlFocusSensorId, selectedModelPath])
|
||||||
|
|
||||||
|
// Автоматическая загрузка модели с order=0 при пустом selectedModelPath
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDefaultModel = async () => {
|
||||||
|
// Если модель уже выбрана или нет objectId - пропускаем
|
||||||
|
if (selectedModelPath || !objectId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[NavigationPage] Auto-loading model with order=0 for object:', objectId);
|
||||||
|
const response = await fetch(`/api/get-zones?object_id=${objectId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && Array.isArray(data.data)) {
|
||||||
|
// Сортируем по order и берём первую
|
||||||
|
const sorted = data.data.slice().sort((a: any, b: any) => {
|
||||||
|
const oa = typeof a.order === 'number' ? a.order : 0;
|
||||||
|
const ob = typeof b.order === 'number' ? b.order : 0;
|
||||||
|
return oa - ob;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sorted.length > 0 && sorted[0].model_path) {
|
||||||
|
console.log('[NavigationPage] Auto-selected model with order=0:', sorted[0].model_path);
|
||||||
|
setSelectedModelPath(sorted[0].model_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[NavigationPage] Failed to load default model:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDefaultModel();
|
||||||
|
}, [selectedModelPath, objectId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDetectors = async () => {
|
||||||
|
try {
|
||||||
|
setDetectorsError(null)
|
||||||
|
|
||||||
|
// Если есть modelPath и objectId - фильтруем по зоне
|
||||||
|
let zoneId: string | null = null
|
||||||
|
if (selectedModelPath && objectId) {
|
||||||
|
try {
|
||||||
|
// Получаем зоны для объекта
|
||||||
|
const zonesRes = await fetch(`/api/get-zones?objectId=${objectId}`, { cache: 'no-store' })
|
||||||
|
if (zonesRes.ok) {
|
||||||
|
const zonesResponse = await zonesRes.json()
|
||||||
|
// API возвращает { success: true, data: [...] }
|
||||||
|
const zonesData = zonesResponse?.data || zonesResponse
|
||||||
|
console.log('[NavigationPage] Loaded zones:', { count: Array.isArray(zonesData) ? zonesData.length : 0, zonesData })
|
||||||
|
// Ищем зону по model_path
|
||||||
|
if (Array.isArray(zonesData)) {
|
||||||
|
const zone = zonesData.find((z: any) => z.model_path === selectedModelPath)
|
||||||
|
if (zone) {
|
||||||
|
zoneId = zone.id
|
||||||
|
console.log('[NavigationPage] Found zone for model_path:', { modelPath: selectedModelPath, zoneId })
|
||||||
|
} else {
|
||||||
|
console.log('[NavigationPage] No zone found for model_path:', selectedModelPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[NavigationPage] Failed to load zones for filtering:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем датчики (с фильтром по зоне если найдена)
|
||||||
|
const detectorsUrl = zoneId
|
||||||
|
? `/api/get-detectors?zone_id=${zoneId}`
|
||||||
|
: '/api/get-detectors'
|
||||||
|
console.log('[NavigationPage] Loading detectors from:', detectorsUrl)
|
||||||
|
|
||||||
|
const res = await fetch(detectorsUrl, { cache: 'no-store' })
|
||||||
|
const text = await res.text()
|
||||||
|
let payload: any
|
||||||
|
try { payload = JSON.parse(text) } catch { payload = text }
|
||||||
|
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()
|
||||||
|
}, [selectedModelPath, objectId])
|
||||||
|
|
||||||
|
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
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import ObjectGallery from '../../../components/objects/ObjectGallery'
|
import ObjectGallery from '../../../components/objects/ObjectGallery'
|
||||||
import { ObjectData } from '../../../components/objects/ObjectCard'
|
import { ObjectData } from '../../../components/objects/ObjectCard'
|
||||||
import Sidebar from '../../../components/ui/Sidebar'
|
|
||||||
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
|
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
@@ -13,7 +12,6 @@ const transformRawToObjectData = (raw: any): ObjectData => {
|
|||||||
const rawId = raw?.id ?? raw?.object_id ?? raw?.uuid ?? raw?.name
|
const rawId = raw?.id ?? raw?.object_id ?? raw?.uuid ?? raw?.name
|
||||||
const object_id = typeof rawId === 'number' ? `object_${rawId}` : String(rawId ?? '')
|
const object_id = typeof rawId === 'number' ? `object_${rawId}` : String(rawId ?? '')
|
||||||
|
|
||||||
// Если объект имеет числовой идентификатор, возвращаем его в виде строки с префиксом 'object_'
|
|
||||||
const deriveTitle = (): string => {
|
const deriveTitle = (): string => {
|
||||||
const t = (raw?.title || '').toString().trim()
|
const t = (raw?.title || '').toString().trim()
|
||||||
if (t) return t
|
if (t) return t
|
||||||
@@ -24,7 +22,6 @@ const transformRawToObjectData = (raw: any): ObjectData => {
|
|||||||
if (typeof numMatch === 'number' && !Number.isNaN(numMatch)) {
|
if (typeof numMatch === 'number' && !Number.isNaN(numMatch)) {
|
||||||
return `Объект ${numMatch}`
|
return `Объект ${numMatch}`
|
||||||
}
|
}
|
||||||
// Если объект не имеет числовой идентификатор, возвращаем его строковый идентификатор
|
|
||||||
return idStr ? `Объект ${idStr}` : `Объект ${object_id}`
|
return idStr ? `Объект ${idStr}` : `Объект ${object_id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +76,6 @@ const ObjectsPage: React.FC = () => {
|
|||||||
} else if (Array.isArray(data?.objects)) {
|
} else if (Array.isArray(data?.objects)) {
|
||||||
rawObjectsArray = data.objects
|
rawObjectsArray = data.objects
|
||||||
} else if (data && typeof data === 'object') {
|
} else if (data && typeof data === 'object') {
|
||||||
// если приходит как map { id: obj }
|
|
||||||
rawObjectsArray = Object.values(data)
|
rawObjectsArray = Object.values(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,8 +99,9 @@ const ObjectsPage: React.FC = () => {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-[#0e111a]">
|
<div className="relative flex items-center justify-center min-h-screen overflow-hidden">
|
||||||
<div className="text-center">
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-10 text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||||
<p className="text-white">Загрузка объектов...</p>
|
<p className="text-white">Загрузка объектов...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,8 +111,9 @@ const ObjectsPage: React.FC = () => {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-[#0e111a]">
|
<div className="relative flex items-center justify-center min-h-screen overflow-hidden">
|
||||||
<div className="text-center">
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-10 text-center">
|
||||||
<div className="text-red-500 mb-4">
|
<div className="text-red-500 mb-4">
|
||||||
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||||
@@ -130,79 +128,47 @@ const ObjectsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
<div className="relative flex flex-col h-screen overflow-hidden">
|
||||||
<AnimatedBackground />
|
<AnimatedBackground />
|
||||||
|
|
||||||
<div className="relative z-20">
|
{/* Header */}
|
||||||
<Sidebar activeItem={null} />
|
<header className="relative z-20 bg-[#161824]/80 backdrop-blur-sm border-b border-blue-500/20">
|
||||||
</div>
|
<div className="flex items-center justify-between px-6 py-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative z-10 flex-1 overflow-y-auto">
|
<Image
|
||||||
{/* Приветствие и информация */}
|
src="/icons/logo.png"
|
||||||
<div className="min-h-screen flex flex-col items-center justify-start pt-20 px-8">
|
alt="AerBIM Logo"
|
||||||
{/* Логотип */}
|
width={150}
|
||||||
<div className="mb-8 flex justify-center">
|
height={40}
|
||||||
<div className="relative w-64 h-20">
|
className="object-contain"
|
||||||
<Image
|
|
||||||
src="/icons/logo.png"
|
|
||||||
alt="AerBIM Logo"
|
|
||||||
width={438}
|
|
||||||
height={60}
|
|
||||||
className="object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Приветствие */}
|
|
||||||
<h1 className="text-5xl font-bold text-white mb-4 text-center animate-fade-in" style={{ fontFamily: 'Inter, sans-serif' }}>
|
|
||||||
Добро пожаловать!
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="text-xl text-gray-300 mb-8 text-center animate-fade-in" style={{ animationDelay: '0.2s', fontFamily: 'Inter, sans-serif' }}>
|
|
||||||
Система мониторинга AerBIM Monitor
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Версия системы */}
|
|
||||||
<div className="mb-16 p-4 rounded-lg bg-gradient-to-r from-blue-500/10 to-cyan-500/10 border border-blue-500/20 inline-block animate-fade-in" style={{ animationDelay: '0.4s' }}>
|
|
||||||
<p className="text-sm text-gray-400" style={{ fontFamily: 'Inter, sans-serif' }}>
|
|
||||||
Версия системы: <span className="text-cyan-400 font-semibold">3.0.0</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Блок с галереей объектов */}
|
|
||||||
<div className="w-full max-w-6xl p-8 rounded-xl bg-gradient-to-r from-blue-500/10 to-cyan-500/10 border border-blue-500/20 backdrop-blur-sm">
|
|
||||||
{/* Заголовок галереи */}
|
|
||||||
<h2 className="text-3xl font-bold text-white mb-8 text-center" style={{ fontFamily: 'Inter, sans-serif' }}>
|
|
||||||
Выберите объект для работы
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Галерея объектов */}
|
|
||||||
<ObjectGallery
|
|
||||||
objects={objects}
|
|
||||||
title=""
|
|
||||||
onObjectSelect={handleObjectSelect}
|
|
||||||
selectedObjectId={selectedObjectId}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
Версия: <span className="text-cyan-400 font-semibold">3.0.0</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="relative z-10 flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||||
|
|
||||||
|
{/* Заголовок */}
|
||||||
|
<h1 className="text-2xl font-bold text-white text-center mb-[34px]">
|
||||||
|
Выберите объект для работы
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Галерея объектов */}
|
||||||
|
<ObjectGallery
|
||||||
|
objects={objects}
|
||||||
|
title=""
|
||||||
|
onObjectSelect={handleObjectSelect}
|
||||||
|
selectedObjectId={selectedObjectId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style jsx>{`
|
|
||||||
@keyframes fade-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in {
|
|
||||||
animation: fade-in 0.8s ease-out forwards;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
208
frontend/app/(protected)/objects/page.tsx — копия 2
Normal file
208
frontend/app/(protected)/objects/page.tsx — копия 2
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import ObjectGallery from '../../../components/objects/ObjectGallery'
|
||||||
|
import { ObjectData } from '../../../components/objects/ObjectCard'
|
||||||
|
import Sidebar from '../../../components/ui/Sidebar'
|
||||||
|
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
|
// Универсальная функция для преобразования объекта из бэкенда в ObjectData
|
||||||
|
const transformRawToObjectData = (raw: any): ObjectData => {
|
||||||
|
const rawId = raw?.id ?? raw?.object_id ?? raw?.uuid ?? raw?.name
|
||||||
|
const object_id = typeof rawId === 'number' ? `object_${rawId}` : String(rawId ?? '')
|
||||||
|
|
||||||
|
// Если объект имеет числовой идентификатор, возвращаем его в виде строки с префиксом 'object_'
|
||||||
|
const deriveTitle = (): string => {
|
||||||
|
const t = (raw?.title || '').toString().trim()
|
||||||
|
if (t) return t
|
||||||
|
const idStr = String(rawId ?? '').toString()
|
||||||
|
const numMatch = typeof rawId === 'number'
|
||||||
|
? rawId
|
||||||
|
: (() => { const m = idStr.match(/\d+/); return m ? Number(m[0]) : undefined })()
|
||||||
|
if (typeof numMatch === 'number' && !Number.isNaN(numMatch)) {
|
||||||
|
return `Объект ${numMatch}`
|
||||||
|
}
|
||||||
|
// Если объект не имеет числовой идентификатор, возвращаем его строковый идентификатор
|
||||||
|
return idStr ? `Объект ${idStr}` : `Объект ${object_id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
object_id,
|
||||||
|
title: deriveTitle(),
|
||||||
|
description: raw?.description ?? `Описание объекта ${raw?.title ?? object_id}`,
|
||||||
|
image: raw?.image ?? null,
|
||||||
|
location: raw?.location ?? raw?.address ?? 'Не указано',
|
||||||
|
floors: Number(raw?.floors ?? 0),
|
||||||
|
area: String(raw?.area ?? ''),
|
||||||
|
type: raw?.type ?? 'object',
|
||||||
|
status: raw?.status ?? 'active',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ObjectsPage: React.FC = () => {
|
||||||
|
const [objects, setObjects] = useState<ObjectData[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const url = '/api/get-objects'
|
||||||
|
const res = await fetch(url, { cache: 'no-store' })
|
||||||
|
const payloadText = await res.text()
|
||||||
|
let payload: any
|
||||||
|
try { payload = JSON.parse(payloadText) } catch { payload = payloadText }
|
||||||
|
console.log('[ObjectsPage] GET /api/get-objects', { status: res.status, payload })
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorMessage = typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить данные объектов')
|
||||||
|
|
||||||
|
if (errorMessage.includes('Authentication required') || res.status === 401) {
|
||||||
|
console.log('[ObjectsPage] Authentication required, redirecting to login')
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (payload?.data ?? payload) as any
|
||||||
|
let rawObjectsArray: any[] = []
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
rawObjectsArray = data
|
||||||
|
} else if (Array.isArray(data?.objects)) {
|
||||||
|
rawObjectsArray = data.objects
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
// если приходит как map { id: obj }
|
||||||
|
rawObjectsArray = Object.values(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformedObjects = rawObjectsArray.map(transformRawToObjectData)
|
||||||
|
setObjects(transformedObjects)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Ошибка при загрузке данных объектов:', err)
|
||||||
|
setError(err?.message || 'Произошла неизвестная ошибка')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData()
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
const handleObjectSelect = (objectId: string) => {
|
||||||
|
console.log('Object selected:', objectId)
|
||||||
|
setSelectedObjectId(objectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-[#0e111a]">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||||
|
<p className="text-white">Загрузка объектов...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-[#0e111a]">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-red-500 mb-4">
|
||||||
|
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">Ошибка загрузки данных</h3>
|
||||||
|
<p className="text-[#71717a] mb-4">{error}</p>
|
||||||
|
<p className="text-sm text-gray-500">Если проблема повторяется, обратитесь к администратору</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
|
||||||
|
{/* Sidebar скрыт на странице выбора объектов */}
|
||||||
|
|
||||||
|
<div className="relative z-10 flex-1 overflow-y-auto">
|
||||||
|
{/* Приветствие и информация */}
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-start pt-8 px-8">
|
||||||
|
{/* Логотип */}
|
||||||
|
<div className="mb-4 flex justify-center">
|
||||||
|
<div className="relative w-64 h-20">
|
||||||
|
<Image
|
||||||
|
src="/icons/logo.png"
|
||||||
|
alt="AerBIM Logo"
|
||||||
|
width={438}
|
||||||
|
height={60}
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Приветствие */}
|
||||||
|
<h1 className="text-5xl font-bold text-white mb-4 text-center animate-fade-in" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
|
Добро пожаловать!
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-gray-300 mb-8 text-center animate-fade-in" style={{ animationDelay: '0.2s', fontFamily: 'Inter, sans-serif' }}>
|
||||||
|
Система мониторинга AerBIM Monitor
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Версия системы */}
|
||||||
|
<div className="mb-6 p-3 rounded-lg bg-gradient-to-r from-blue-500/10 to-cyan-500/10 border border-blue-500/20 inline-block">
|
||||||
|
<p className="text-sm text-gray-400" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
|
Версия системы: <span className="text-cyan-400 font-semibold">3.0.0</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Блок с галереей объектов */}
|
||||||
|
<div className="w-full max-w-6xl p-4 rounded-xl bg-gradient-to-r from-blue-500/10 to-cyan-500/10 border border-blue-500/20 backdrop-blur-sm">
|
||||||
|
{/* Заголовок галереи */}
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-4 text-center" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
|
Выберите объект для работы
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Галерея объектов */}
|
||||||
|
<ObjectGallery
|
||||||
|
objects={objects}
|
||||||
|
title=""
|
||||||
|
onObjectSelect={handleObjectSelect}
|
||||||
|
selectedObjectId={selectedObjectId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.8s ease-out forwards;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ObjectsPage
|
||||||
176
frontend/app/(protected)/objects/page.tsx — копия 3
Normal file
176
frontend/app/(protected)/objects/page.tsx — копия 3
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import ObjectGallery from '../../../components/objects/ObjectGallery'
|
||||||
|
import { ObjectData } from '../../../components/objects/ObjectCard'
|
||||||
|
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
|
// Универсальная функция для преобразования объекта из бэкенда в ObjectData
|
||||||
|
const transformRawToObjectData = (raw: any): ObjectData => {
|
||||||
|
const rawId = raw?.id ?? raw?.object_id ?? raw?.uuid ?? raw?.name
|
||||||
|
const object_id = typeof rawId === 'number' ? `object_${rawId}` : String(rawId ?? '')
|
||||||
|
|
||||||
|
const deriveTitle = (): string => {
|
||||||
|
const t = (raw?.title || '').toString().trim()
|
||||||
|
if (t) return t
|
||||||
|
const idStr = String(rawId ?? '').toString()
|
||||||
|
const numMatch = typeof rawId === 'number'
|
||||||
|
? rawId
|
||||||
|
: (() => { const m = idStr.match(/\d+/); return m ? Number(m[0]) : undefined })()
|
||||||
|
if (typeof numMatch === 'number' && !Number.isNaN(numMatch)) {
|
||||||
|
return `Объект ${numMatch}`
|
||||||
|
}
|
||||||
|
return idStr ? `Объект ${idStr}` : `Объект ${object_id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
object_id,
|
||||||
|
title: deriveTitle(),
|
||||||
|
description: raw?.description ?? `Описание объекта ${raw?.title ?? object_id}`,
|
||||||
|
image: raw?.image ?? null,
|
||||||
|
location: raw?.location ?? raw?.address ?? 'Не указано',
|
||||||
|
floors: Number(raw?.floors ?? 0),
|
||||||
|
area: String(raw?.area ?? ''),
|
||||||
|
type: raw?.type ?? 'object',
|
||||||
|
status: raw?.status ?? 'active',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ObjectsPage: React.FC = () => {
|
||||||
|
const [objects, setObjects] = useState<ObjectData[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const url = '/api/get-objects'
|
||||||
|
const res = await fetch(url, { cache: 'no-store' })
|
||||||
|
const payloadText = await res.text()
|
||||||
|
let payload: any
|
||||||
|
try { payload = JSON.parse(payloadText) } catch { payload = payloadText }
|
||||||
|
console.log('[ObjectsPage] GET /api/get-objects', { status: res.status, payload })
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorMessage = typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить данные объектов')
|
||||||
|
|
||||||
|
if (errorMessage.includes('Authentication required') || res.status === 401) {
|
||||||
|
console.log('[ObjectsPage] Authentication required, redirecting to login')
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (payload?.data ?? payload) as any
|
||||||
|
let rawObjectsArray: any[] = []
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
rawObjectsArray = data
|
||||||
|
} else if (Array.isArray(data?.objects)) {
|
||||||
|
rawObjectsArray = data.objects
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
rawObjectsArray = Object.values(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformedObjects = rawObjectsArray.map(transformRawToObjectData)
|
||||||
|
setObjects(transformedObjects)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Ошибка при загрузке данных объектов:', err)
|
||||||
|
setError(err?.message || 'Произошла неизвестная ошибка')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData()
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
const handleObjectSelect = (objectId: string) => {
|
||||||
|
console.log('Object selected:', objectId)
|
||||||
|
setSelectedObjectId(objectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center min-h-screen overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-10 text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||||
|
<p className="text-white">Загрузка объектов...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center min-h-screen overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-10 text-center">
|
||||||
|
<div className="text-red-500 mb-4">
|
||||||
|
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">Ошибка загрузки данных</h3>
|
||||||
|
<p className="text-[#71717a] mb-4">{error}</p>
|
||||||
|
<p className="text-sm text-gray-500">Если проблема повторяется, обратитесь к администратору</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col h-screen overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<header className="relative z-20 bg-[#161824]/80 backdrop-blur-sm border-b border-blue-500/20">
|
||||||
|
<div className="flex items-center justify-between px-6 py-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Image
|
||||||
|
src="/icons/logo.png"
|
||||||
|
alt="AerBIM Logo"
|
||||||
|
width={150}
|
||||||
|
height={40}
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
Версия: <span className="text-cyan-400 font-semibold">3.0.0</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="relative z-10 flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||||
|
|
||||||
|
{/* Заголовок */}
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-6">
|
||||||
|
Выберите объект для работы
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Галерея объектов */}
|
||||||
|
<ObjectGallery
|
||||||
|
objects={objects}
|
||||||
|
title=""
|
||||||
|
onObjectSelect={handleObjectSelect}
|
||||||
|
selectedObjectId={selectedObjectId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ObjectsPage
|
||||||
@@ -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}`,
|
||||||
@@ -71,8 +82,21 @@ export async function GET() {
|
|||||||
detector_type: sensor.detector_type ?? '',
|
detector_type: sensor.detector_type ?? '',
|
||||||
notifications: Array.isArray(sensor.notifications) ? sensor.notifications.map((n: any) => {
|
notifications: Array.isArray(sensor.notifications) ? sensor.notifications.map((n: any) => {
|
||||||
const severity = String(n?.severity || n?.type || '').toLowerCase()
|
const severity = String(n?.severity || n?.type || '').toLowerCase()
|
||||||
const type = severity === 'critical' ? 'critical' : severity === 'warning' ? 'warning' : 'info'
|
|
||||||
const priority = severity === 'critical' ? 'high' : severity === 'warning' ? 'medium' : 'low'
|
// Логируем оригинальные данные для отладки
|
||||||
|
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 {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type,
|
type,
|
||||||
|
|||||||
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
104
frontend/app/api/get-detectors/route — копия.ts
Normal file
104
frontend/app/api/get-detectors/route — копия.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
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()
|
||||||
|
const type = severity === 'critical' ? 'critical' : severity === 'warning' ? 'warning' : 'info'
|
||||||
|
const priority = severity === 'critical' ? 'high' : severity === '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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,6 +117,9 @@ export interface NavigationStore {
|
|||||||
isOnNavigationPage: () => boolean
|
isOnNavigationPage: () => boolean
|
||||||
getCurrentRoute: () => string | null
|
getCurrentRoute: () => string | null
|
||||||
getActiveSidebarItem: () => number
|
getActiveSidebarItem: () => number
|
||||||
|
|
||||||
|
// Навигация к датчику на 3D-модели
|
||||||
|
navigateToSensor: (sensorSerialNumber: string, floor: number | null, viewType: 'building' | 'floor') => Promise<{ sensorSerialNumber: string; modelPath: string } | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
const useNavigationStore = create<NavigationStore>()(
|
const useNavigationStore = create<NavigationStore>()(
|
||||||
@@ -368,6 +371,122 @@ const useNavigationStore = create<NavigationStore>()(
|
|||||||
if (showListOfDetectors) return 7 // Список датчиков
|
if (showListOfDetectors) return 7 // Список датчиков
|
||||||
if (showSensors) return 8 // Сенсоры
|
if (showSensors) return 8 // Сенсоры
|
||||||
return 2 // Навигация (базовая)
|
return 2 // Навигация (базовая)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Навигация к датчику на 3D-модели
|
||||||
|
navigateToSensor: async (sensorSerialNumber: string, floor: number | null, viewType: 'building' | 'floor') => {
|
||||||
|
const { currentObject, loadZones } = get()
|
||||||
|
|
||||||
|
if (!currentObject.id) {
|
||||||
|
console.error('[navigateToSensor] No current object selected')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем зоны для объекта (из кэша или API)
|
||||||
|
await loadZones(currentObject.id)
|
||||||
|
|
||||||
|
const { currentZones } = get()
|
||||||
|
|
||||||
|
if (!currentZones || currentZones.length === 0) {
|
||||||
|
console.error('[navigateToSensor] No zones available for object', currentObject.id)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetZone: Zone | undefined
|
||||||
|
|
||||||
|
if (viewType === 'building') {
|
||||||
|
// Для общего вида здания - ищем самую верхнюю зону (первую в списке)
|
||||||
|
targetZone = currentZones[0]
|
||||||
|
console.log('[navigateToSensor] Building view - selected first zone:', targetZone?.name)
|
||||||
|
} else if (viewType === 'floor') {
|
||||||
|
// Для вида на этаже - ищем зону, где есть этот датчик (исключая order=0)
|
||||||
|
// Фильтруем зоны: исключаем общий план (order=0)
|
||||||
|
const floorZones = currentZones.filter(z => z.order !== 0 && z.model_path)
|
||||||
|
|
||||||
|
console.log('[navigateToSensor] Searching in floor zones (excluding order=0):', floorZones.length)
|
||||||
|
console.log('[navigateToSensor] Floor zones:', floorZones.map(z => ({ id: z.id, name: z.name, order: z.order, floor: z.floor })))
|
||||||
|
console.log('[navigateToSensor] Looking for sensor:', sensorSerialNumber)
|
||||||
|
|
||||||
|
// Загружаем датчики для каждой зоны и ищем нужный
|
||||||
|
for (const zone of floorZones) {
|
||||||
|
try {
|
||||||
|
console.log(`[navigateToSensor] Checking zone: ${zone.name} (id: ${zone.id}, order: ${zone.order}, floor: ${zone.floor})`)
|
||||||
|
|
||||||
|
const res = await fetch(`/api/get-detectors?zone_id=${zone.id}`, { cache: 'no-store' })
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn(`[navigateToSensor] API request failed for zone ${zone.id}:`, res.status, res.statusText)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await res.json()
|
||||||
|
const data = payload?.data ?? payload
|
||||||
|
const detectorsObj = (data?.detectors ?? {}) as Record<string, any>
|
||||||
|
const detectorsList = Object.values(detectorsObj)
|
||||||
|
|
||||||
|
console.log(`[navigateToSensor] Zone ${zone.name} has ${detectorsList.length} detectors:`, detectorsList.map((d: any) => d.serial_number || d.name))
|
||||||
|
|
||||||
|
// Проверяем есть ли датчик в этой зоне
|
||||||
|
const hasSensor = detectorsList.some((d: any) =>
|
||||||
|
d.serial_number === sensorSerialNumber ||
|
||||||
|
d.name === sensorSerialNumber
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(`[navigateToSensor] Sensor ${sensorSerialNumber} found in zone ${zone.name}:`, hasSensor)
|
||||||
|
|
||||||
|
if (hasSensor) {
|
||||||
|
targetZone = zone
|
||||||
|
console.log('[navigateToSensor] ✅ FOUND! Selected zone:', zone.name, 'zoneId:', zone.id, 'model_path:', zone.model_path)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[navigateToSensor] Failed to load detectors for zone:', zone.id, e)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback на общий вид, если не нашли зону этажа
|
||||||
|
if (!targetZone) {
|
||||||
|
console.warn(`[navigateToSensor] No floor zone found with sensor ${sensorSerialNumber}, falling back to building view`)
|
||||||
|
targetZone = currentZones[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetZone || !targetZone.model_path) {
|
||||||
|
console.error('[navigateToSensor] No valid zone with model_path found')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем состояние для навигации
|
||||||
|
set({
|
||||||
|
currentModelPath: targetZone.model_path,
|
||||||
|
// Открываем Зоны контроля (Monitoring) - она автоматически закроется после загрузки модели
|
||||||
|
showMonitoring: true,
|
||||||
|
// Закрываем остальные меню
|
||||||
|
showFloorNavigation: false,
|
||||||
|
showNotifications: false,
|
||||||
|
showListOfDetectors: false,
|
||||||
|
// НЕ закрываем showSensors - оставляем как есть для подсветки датчиков
|
||||||
|
// showSensors: false, <- Убрали!
|
||||||
|
showDetectorMenu: false,
|
||||||
|
showAlertMenu: false,
|
||||||
|
selectedDetector: null,
|
||||||
|
selectedAlert: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[navigateToSensor] Navigation prepared:', {
|
||||||
|
sensorSerialNumber,
|
||||||
|
floor,
|
||||||
|
viewType,
|
||||||
|
modelPath: targetZone.model_path,
|
||||||
|
zoneName: targetZone.name,
|
||||||
|
zoneId: targetZone.id
|
||||||
|
})
|
||||||
|
|
||||||
|
// Возвращаем объект с sensorSerialNumber и modelPath
|
||||||
|
return {
|
||||||
|
sensorSerialNumber,
|
||||||
|
modelPath: targetZone.model_path
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface NavigationStore {
|
|||||||
showNotifications: boolean
|
showNotifications: boolean
|
||||||
showListOfDetectors: boolean
|
showListOfDetectors: boolean
|
||||||
showSensors: boolean
|
showSensors: boolean
|
||||||
|
showSensorHighlights: boolean
|
||||||
|
|
||||||
selectedDetector: DetectorType | null
|
selectedDetector: DetectorType | null
|
||||||
showDetectorMenu: boolean
|
showDetectorMenu: boolean
|
||||||
@@ -100,6 +101,8 @@ export interface NavigationStore {
|
|||||||
closeListOfDetectors: () => void
|
closeListOfDetectors: () => void
|
||||||
openSensors: () => void
|
openSensors: () => void
|
||||||
closeSensors: () => void
|
closeSensors: () => void
|
||||||
|
toggleSensorHighlights: () => void
|
||||||
|
setSensorHighlights: (show: boolean) => void
|
||||||
|
|
||||||
closeAllMenus: () => void
|
closeAllMenus: () => void
|
||||||
clearSelections: () => void
|
clearSelections: () => void
|
||||||
@@ -114,6 +117,9 @@ export interface NavigationStore {
|
|||||||
isOnNavigationPage: () => boolean
|
isOnNavigationPage: () => boolean
|
||||||
getCurrentRoute: () => string | null
|
getCurrentRoute: () => string | null
|
||||||
getActiveSidebarItem: () => number
|
getActiveSidebarItem: () => number
|
||||||
|
|
||||||
|
// Навигация к датчику на 3D-модели
|
||||||
|
navigateToSensor: (sensorSerialNumber: string, floor: number | null, viewType: 'building' | 'floor') => Promise<string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
const useNavigationStore = create<NavigationStore>()(
|
const useNavigationStore = create<NavigationStore>()(
|
||||||
@@ -137,6 +143,7 @@ const useNavigationStore = create<NavigationStore>()(
|
|||||||
showNotifications: false,
|
showNotifications: false,
|
||||||
showListOfDetectors: false,
|
showListOfDetectors: false,
|
||||||
showSensors: false,
|
showSensors: false,
|
||||||
|
showSensorHighlights: true,
|
||||||
|
|
||||||
selectedDetector: null,
|
selectedDetector: null,
|
||||||
showDetectorMenu: false,
|
showDetectorMenu: false,
|
||||||
@@ -316,6 +323,9 @@ const useNavigationStore = create<NavigationStore>()(
|
|||||||
currentSubmenu: null
|
currentSubmenu: null
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
toggleSensorHighlights: () => set((state) => ({ showSensorHighlights: !state.showSensorHighlights })),
|
||||||
|
setSensorHighlights: (show: boolean) => set({ showSensorHighlights: show }),
|
||||||
|
|
||||||
closeAllMenus: () => {
|
closeAllMenus: () => {
|
||||||
set({
|
set({
|
||||||
showMonitoring: false,
|
showMonitoring: false,
|
||||||
@@ -361,6 +371,105 @@ const useNavigationStore = create<NavigationStore>()(
|
|||||||
if (showListOfDetectors) return 7 // Список датчиков
|
if (showListOfDetectors) return 7 // Список датчиков
|
||||||
if (showSensors) return 8 // Сенсоры
|
if (showSensors) return 8 // Сенсоры
|
||||||
return 2 // Навигация (базовая)
|
return 2 // Навигация (базовая)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Навигация к датчику на 3D-модели
|
||||||
|
navigateToSensor: async (sensorSerialNumber: string, floor: number | null, viewType: 'building' | 'floor') => {
|
||||||
|
const { currentObject, loadZones } = get()
|
||||||
|
|
||||||
|
if (!currentObject.id) {
|
||||||
|
console.error('[navigateToSensor] No current object selected')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем зоны для объекта (из кэша или API)
|
||||||
|
await loadZones(currentObject.id)
|
||||||
|
|
||||||
|
const { currentZones } = get()
|
||||||
|
|
||||||
|
if (!currentZones || currentZones.length === 0) {
|
||||||
|
console.error('[navigateToSensor] No zones available for object', currentObject.id)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetZone: Zone | undefined
|
||||||
|
|
||||||
|
if (viewType === 'building') {
|
||||||
|
// Для общего вида здания - ищем самую верхнюю зону (первую в списке)
|
||||||
|
targetZone = currentZones[0]
|
||||||
|
console.log('[navigateToSensor] Building view - selected first zone:', targetZone?.name)
|
||||||
|
} else if (viewType === 'floor') {
|
||||||
|
// Для вида на этаже - ищем зону, где есть этот датчик
|
||||||
|
// Сначала проверяем зоны с sensors массивом
|
||||||
|
for (const zone of currentZones) {
|
||||||
|
if (zone.sensors && Array.isArray(zone.sensors)) {
|
||||||
|
const hasSensor = zone.sensors.some(s =>
|
||||||
|
s.serial_number === sensorSerialNumber ||
|
||||||
|
s.name === sensorSerialNumber
|
||||||
|
)
|
||||||
|
if (hasSensor) {
|
||||||
|
targetZone = zone
|
||||||
|
console.log('[navigateToSensor] Found sensor in zone:', zone.name, 'sensors:', zone.sensors.length)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если не нашли по sensors, пробуем по floor
|
||||||
|
if (!targetZone && floor !== null) {
|
||||||
|
// Ищем зоны с соответствующим floor (кроме общего вида)
|
||||||
|
const floorZones = currentZones.filter(z =>
|
||||||
|
z.floor === floor &&
|
||||||
|
z.order !== 0 &&
|
||||||
|
z.model_path
|
||||||
|
)
|
||||||
|
|
||||||
|
if (floorZones.length > 0) {
|
||||||
|
targetZone = floorZones[0]
|
||||||
|
console.log('[navigateToSensor] Found zone by floor:', targetZone.name, 'floor:', floor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback на общий вид, если не нашли зону этажа
|
||||||
|
if (!targetZone) {
|
||||||
|
console.warn(`[navigateToSensor] No zone found with sensor ${sensorSerialNumber} or floor ${floor}, falling back to building view`)
|
||||||
|
targetZone = currentZones[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetZone || !targetZone.model_path) {
|
||||||
|
console.error('[navigateToSensor] No valid zone with model_path found')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем состояние для навигации
|
||||||
|
set({
|
||||||
|
currentModelPath: targetZone.model_path,
|
||||||
|
// Открываем Зоны контроля (Monitoring) - она автоматически закроется после загрузки модели
|
||||||
|
showMonitoring: true,
|
||||||
|
// Закрываем остальные меню
|
||||||
|
showFloorNavigation: false,
|
||||||
|
showNotifications: false,
|
||||||
|
showListOfDetectors: false,
|
||||||
|
// НЕ закрываем showSensors - оставляем как есть для подсветки датчиков
|
||||||
|
// showSensors: false, <- Убрали!
|
||||||
|
showDetectorMenu: false,
|
||||||
|
showAlertMenu: false,
|
||||||
|
selectedDetector: null,
|
||||||
|
selectedAlert: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[navigateToSensor] Navigation prepared:', {
|
||||||
|
sensorSerialNumber,
|
||||||
|
floor,
|
||||||
|
viewType,
|
||||||
|
modelPath: targetZone.model_path,
|
||||||
|
zoneName: targetZone.name,
|
||||||
|
zoneId: targetZone.id
|
||||||
|
})
|
||||||
|
|
||||||
|
// Возвращаем serial_number для установки focusedSensorId в компоненте
|
||||||
|
return sensorSerialNumber
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useState, useMemo } from 'react'
|
import React, { useState, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useNavigationStore from '../../app/store/navigationStore'
|
||||||
import * as statusColors from '../../lib/statusColors'
|
import * as statusColors from '../../lib/statusColors'
|
||||||
|
|
||||||
interface AlertItem {
|
interface AlertItem {
|
||||||
@@ -12,6 +14,8 @@ interface AlertItem {
|
|||||||
detector_name?: string
|
detector_name?: string
|
||||||
location?: string
|
location?: string
|
||||||
object?: string
|
object?: string
|
||||||
|
serial_number?: string
|
||||||
|
floor?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AlertsListProps {
|
interface AlertsListProps {
|
||||||
@@ -21,6 +25,8 @@ interface AlertsListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, initialSearchTerm = '' }) => {
|
const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, initialSearchTerm = '' }) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const { navigateToSensor } = useNavigationStore()
|
||||||
const [searchTerm, setSearchTerm] = useState(initialSearchTerm)
|
const [searchTerm, setSearchTerm] = useState(initialSearchTerm)
|
||||||
|
|
||||||
const filteredAlerts = useMemo(() => {
|
const filteredAlerts = useMemo(() => {
|
||||||
@@ -46,6 +52,26 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleGoTo3D = async (alert: AlertItem, viewType: 'building' | 'floor') => {
|
||||||
|
// Используем доступные идентификаторы датчика
|
||||||
|
const sensorId = alert.serial_number || alert.detector_name || alert.detector_id
|
||||||
|
|
||||||
|
if (!sensorId) {
|
||||||
|
console.warn('[AlertsList] Alert missing sensor identifier:', alert)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await navigateToSensor(
|
||||||
|
sensorId,
|
||||||
|
alert.floor || null,
|
||||||
|
viewType
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
router.push(`/navigation?focusSensorId=${encodeURIComponent(result.sensorSerialNumber)}&modelPath=${encodeURIComponent(result.modelPath)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Поиск */}
|
{/* Поиск */}
|
||||||
@@ -77,10 +103,10 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
|
|||||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
|
||||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Статус</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Статус</th>
|
||||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
|
||||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Местоположение</th>
|
|
||||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Приоритет</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Приоритет</th>
|
||||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Подтверждено</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Подтверждено</th>
|
||||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Время</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Время</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-center text-white text-sm py-3">3D Вид</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -106,9 +132,6 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
|
|||||||
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
||||||
{item.message}
|
{item.message}
|
||||||
</td>
|
</td>
|
||||||
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
|
||||||
{item.location || '-'}
|
|
||||||
</td>
|
|
||||||
<td className="py-4">
|
<td className="py-4">
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
|
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
|
||||||
@@ -141,6 +164,28 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
|
|||||||
<td style={interRegularStyle} className="py-4 text-sm text-gray-300">
|
<td style={interRegularStyle} className="py-4 text-sm text-gray-300">
|
||||||
{new Date(item.timestamp).toLocaleString('ru-RU')}
|
{new Date(item.timestamp).toLocaleString('ru-RU')}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleGoTo3D(item, 'building')}
|
||||||
|
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
|
||||||
|
title="Показать на общей модели"
|
||||||
|
>
|
||||||
|
<img src="/icons/Building3D.png" alt="Здание" className="w-5 h-5 opacity-70 group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleGoTo3D(item, 'floor')}
|
||||||
|
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
|
||||||
|
title="Показать на этаже"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/icons/Floor3D.png"
|
||||||
|
alt="Этаж"
|
||||||
|
className="w-5 h-5 opacity-70 group-hover:opacity-100"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{filteredAlerts.length === 0 && (
|
{filteredAlerts.length === 0 && (
|
||||||
|
|||||||
161
frontend/components/alerts/AlertsList.tsx — копия
Normal file
161
frontend/components/alerts/AlertsList.tsx — копия
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import React, { useState, useMemo } from 'react'
|
||||||
|
import * as statusColors from '../../lib/statusColors'
|
||||||
|
|
||||||
|
interface AlertItem {
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
detector_id?: string
|
||||||
|
detector_name?: string
|
||||||
|
location?: string
|
||||||
|
object?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertsListProps {
|
||||||
|
alerts: AlertItem[]
|
||||||
|
onAcknowledgeToggle: (alertId: number) => void
|
||||||
|
initialSearchTerm?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, initialSearchTerm = '' }) => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState(initialSearchTerm)
|
||||||
|
|
||||||
|
const filteredAlerts = useMemo(() => {
|
||||||
|
return alerts.filter(alert => {
|
||||||
|
const matchesSearch = searchTerm === '' || alert.detector_id?.toString() === searchTerm
|
||||||
|
return matchesSearch
|
||||||
|
})
|
||||||
|
}, [alerts, searchTerm])
|
||||||
|
|
||||||
|
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
|
||||||
|
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
|
||||||
|
|
||||||
|
const getStatusColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'critical':
|
||||||
|
return statusColors.STATUS_COLOR_CRITICAL
|
||||||
|
case 'warning':
|
||||||
|
return statusColors.STATUS_COLOR_WARNING
|
||||||
|
case 'info':
|
||||||
|
return statusColors.STATUS_COLOR_NORMAL
|
||||||
|
default:
|
||||||
|
return statusColors.STATUS_COLOR_UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Поиск */}
|
||||||
|
<div className="flex items-center justify-end gap-4 mb-6">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по ID детектора..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64"
|
||||||
|
/>
|
||||||
|
<svg className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Таблица алертов */}
|
||||||
|
<div className="bg-[#161824] rounded-[20px] p-6">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 style={interSemiboldStyle} className="text-xl text-white">История тревог</h2>
|
||||||
|
<span style={interRegularStyle} className="text-sm text-gray-400">Всего: {filteredAlerts.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-700">
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Статус</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Местоположение</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Приоритет</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Подтверждено</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Время</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredAlerts.map((item) => (
|
||||||
|
<tr key={item.id} className="border-b border-gray-700 hover:bg-gray-800/50 transition-colors">
|
||||||
|
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
||||||
|
<div>{item.detector_name || 'Детектор'}</div>
|
||||||
|
{item.detector_id ? (
|
||||||
|
<div className="text-gray-400">ID: {item.detector_id}</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: getStatusColor(item.type) }}
|
||||||
|
></div>
|
||||||
|
<span style={interRegularStyle} className="text-sm text-gray-300">
|
||||||
|
{item.type === 'critical' ? 'Критический' : item.type === 'warning' ? 'Предупреждение' : 'Информация'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
||||||
|
{item.message}
|
||||||
|
</td>
|
||||||
|
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
||||||
|
{item.location || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
item.priority === 'high'
|
||||||
|
? statusColors.STATUS_COLOR_CRITICAL
|
||||||
|
: item.priority === 'medium'
|
||||||
|
? statusColors.STATUS_COLOR_WARNING
|
||||||
|
: statusColors.STATUS_COLOR_NORMAL,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.priority === 'high' ? 'Высокий' : item.priority === 'medium' ? 'Средний' : 'Низкий'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<span style={interRegularStyle} className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs ${
|
||||||
|
item.acknowledged ? 'bg-green-600/20 text-green-300 ring-1 ring-green-600/40' : 'bg-red-600/20 text-red-300 ring-1 ring-red-600/40'
|
||||||
|
}`}>
|
||||||
|
{item.acknowledged ? 'Да' : 'Нет'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onAcknowledgeToggle(item.id)}
|
||||||
|
style={interRegularStyle}
|
||||||
|
className="ml-2 inline-flex items-center px-2 py-1 rounded text-xs bg-[#2a2e3e] text-white hover:bg-[#353a4d]"
|
||||||
|
>
|
||||||
|
{item.acknowledged ? 'Снять' : 'Подтвердить'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td style={interRegularStyle} className="py-4 text-sm text-gray-300">
|
||||||
|
{new Date(item.timestamp).toLocaleString('ru-RU')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{filteredAlerts.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="py-8 text-center text-gray-400">
|
||||||
|
Записей не найдено
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlertsList
|
||||||
206
frontend/components/alerts/AlertsList.tsx — копия 2
Normal file
206
frontend/components/alerts/AlertsList.tsx — копия 2
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import React, { useState, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useNavigationStore from '../../app/store/navigationStore'
|
||||||
|
import * as statusColors from '../../lib/statusColors'
|
||||||
|
|
||||||
|
interface AlertItem {
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
detector_id?: string
|
||||||
|
detector_name?: string
|
||||||
|
location?: string
|
||||||
|
object?: string
|
||||||
|
serial_number?: string
|
||||||
|
floor?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertsListProps {
|
||||||
|
alerts: AlertItem[]
|
||||||
|
onAcknowledgeToggle: (alertId: number) => void
|
||||||
|
initialSearchTerm?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, initialSearchTerm = '' }) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const { navigateToSensor } = useNavigationStore()
|
||||||
|
const [searchTerm, setSearchTerm] = useState(initialSearchTerm)
|
||||||
|
|
||||||
|
const filteredAlerts = useMemo(() => {
|
||||||
|
return alerts.filter(alert => {
|
||||||
|
const matchesSearch = searchTerm === '' || alert.detector_id?.toString() === searchTerm
|
||||||
|
return matchesSearch
|
||||||
|
})
|
||||||
|
}, [alerts, searchTerm])
|
||||||
|
|
||||||
|
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
|
||||||
|
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
|
||||||
|
|
||||||
|
const getStatusColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'critical':
|
||||||
|
return statusColors.STATUS_COLOR_CRITICAL
|
||||||
|
case 'warning':
|
||||||
|
return statusColors.STATUS_COLOR_WARNING
|
||||||
|
case 'info':
|
||||||
|
return statusColors.STATUS_COLOR_NORMAL
|
||||||
|
default:
|
||||||
|
return statusColors.STATUS_COLOR_UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGoTo3D = async (alert: AlertItem, viewType: 'building' | 'floor') => {
|
||||||
|
// Используем доступные идентификаторы датчика
|
||||||
|
const sensorId = alert.serial_number || alert.detector_name || alert.detector_id
|
||||||
|
|
||||||
|
if (!sensorId) {
|
||||||
|
console.warn('[AlertsList] Alert missing sensor identifier:', alert)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensorSerialNumber = await navigateToSensor(
|
||||||
|
sensorId,
|
||||||
|
alert.floor || null,
|
||||||
|
viewType
|
||||||
|
)
|
||||||
|
|
||||||
|
if (sensorSerialNumber) {
|
||||||
|
router.push(`/navigation?focusSensorId=${encodeURIComponent(sensorSerialNumber)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Поиск */}
|
||||||
|
<div className="flex items-center justify-end gap-4 mb-6">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по ID детектора..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64"
|
||||||
|
/>
|
||||||
|
<svg className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Таблица алертов */}
|
||||||
|
<div className="bg-[#161824] rounded-[20px] p-6">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 style={interSemiboldStyle} className="text-xl text-white">История тревог</h2>
|
||||||
|
<span style={interRegularStyle} className="text-sm text-gray-400">Всего: {filteredAlerts.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-700">
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Статус</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Приоритет</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Подтверждено</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Время</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-center text-white text-sm py-3">3D Вид</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredAlerts.map((item) => (
|
||||||
|
<tr key={item.id} className="border-b border-gray-700 hover:bg-gray-800/50 transition-colors">
|
||||||
|
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
||||||
|
<div>{item.detector_name || 'Детектор'}</div>
|
||||||
|
{item.detector_id ? (
|
||||||
|
<div className="text-gray-400">ID: {item.detector_id}</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: getStatusColor(item.type) }}
|
||||||
|
></div>
|
||||||
|
<span style={interRegularStyle} className="text-sm text-gray-300">
|
||||||
|
{item.type === 'critical' ? 'Критический' : item.type === 'warning' ? 'Предупреждение' : 'Информация'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
||||||
|
{item.message}
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
item.priority === 'high'
|
||||||
|
? statusColors.STATUS_COLOR_CRITICAL
|
||||||
|
: item.priority === 'medium'
|
||||||
|
? statusColors.STATUS_COLOR_WARNING
|
||||||
|
: statusColors.STATUS_COLOR_NORMAL,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.priority === 'high' ? 'Высокий' : item.priority === 'medium' ? 'Средний' : 'Низкий'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<span style={interRegularStyle} className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs ${
|
||||||
|
item.acknowledged ? 'bg-green-600/20 text-green-300 ring-1 ring-green-600/40' : 'bg-red-600/20 text-red-300 ring-1 ring-red-600/40'
|
||||||
|
}`}>
|
||||||
|
{item.acknowledged ? 'Да' : 'Нет'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onAcknowledgeToggle(item.id)}
|
||||||
|
style={interRegularStyle}
|
||||||
|
className="ml-2 inline-flex items-center px-2 py-1 rounded text-xs bg-[#2a2e3e] text-white hover:bg-[#353a4d]"
|
||||||
|
>
|
||||||
|
{item.acknowledged ? 'Снять' : 'Подтвердить'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td style={interRegularStyle} className="py-4 text-sm text-gray-300">
|
||||||
|
{new Date(item.timestamp).toLocaleString('ru-RU')}
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleGoTo3D(item, 'building')}
|
||||||
|
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
|
||||||
|
title="Показать на общей модели"
|
||||||
|
>
|
||||||
|
<img src="/icons/Building3D.png" alt="Здание" className="w-5 h-5 opacity-70 group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleGoTo3D(item, 'floor')}
|
||||||
|
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
|
||||||
|
title="Показать на этаже"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/icons/Floor3D.png"
|
||||||
|
alt="Этаж"
|
||||||
|
className="w-5 h-5 opacity-70 group-hover:opacity-100"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{filteredAlerts.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="py-8 text-center text-gray-400">
|
||||||
|
Записей не найдено
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlertsList
|
||||||
@@ -38,10 +38,37 @@ interface DetectorListProps {
|
|||||||
onDetectorSelect: (detectorId: number, selected: boolean) => void
|
onDetectorSelect: (detectorId: number, selected: boolean) => void
|
||||||
initialSearchTerm?: string
|
initialSearchTerm?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Функция для генерации умного диапазона страниц
|
||||||
|
function getPaginationRange(currentPage: number, totalPages: number): (number | string)[] {
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
// Если страниц мало - показываем все
|
||||||
|
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Всегда показываем первые 3 и последние 3
|
||||||
|
const start = [1, 2, 3]
|
||||||
|
const end = [totalPages - 2, totalPages - 1, totalPages]
|
||||||
|
|
||||||
|
if (currentPage <= 4) {
|
||||||
|
// Начало: 1 2 3 4 5 ... 11 12 13
|
||||||
|
return [1, 2, 3, 4, 5, '...', ...end]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage >= totalPages - 3) {
|
||||||
|
// Конец: 1 2 3 ... 9 10 11 12 13
|
||||||
|
return [...start, '...', totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Середина: 1 2 3 ... 6 7 8 ... 11 12 13
|
||||||
|
return [...start, '...', currentPage - 1, currentPage, currentPage + 1, '...', ...end]
|
||||||
|
}
|
||||||
|
|
||||||
const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors, onDetectorSelect, initialSearchTerm = '' }) => {
|
const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors, onDetectorSelect, initialSearchTerm = '' }) => {
|
||||||
const [detectors, setDetectors] = useState<Detector[]>([])
|
const [detectors, setDetectors] = useState<Detector[]>([])
|
||||||
const [searchTerm, setSearchTerm] = useState<string>(initialSearchTerm)
|
const [searchTerm, setSearchTerm] = useState<string>(initialSearchTerm)
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState(10)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDetectors = async () => {
|
const loadDetectors = async () => {
|
||||||
@@ -53,6 +80,7 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
|||||||
const rawArray: RawDetector[] = Object.values(detectorsData).filter(
|
const rawArray: RawDetector[] = Object.values(detectorsData).filter(
|
||||||
(detector) => (objectId ? detector.object === objectId : true)
|
(detector) => (objectId ? detector.object === objectId : true)
|
||||||
)
|
)
|
||||||
|
|
||||||
const normalized: Detector[] = rawArray.map((d) => ({
|
const normalized: Detector[] = rawArray.map((d) => ({
|
||||||
detector_id: d.detector_id,
|
detector_id: d.detector_id,
|
||||||
name: d.name,
|
name: d.name,
|
||||||
@@ -62,7 +90,8 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
|||||||
floor: d.floor,
|
floor: d.floor,
|
||||||
checked: false,
|
checked: false,
|
||||||
}))
|
}))
|
||||||
console.log('[DetectorList] Payload:', payload)
|
console.log('[DetectorList] Payload:', payload)
|
||||||
|
console.log('[DetectorList] Total detectors:', normalized.length)
|
||||||
setDetectors(normalized)
|
setDetectors(normalized)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load detectors:', e)
|
console.error('Failed to load detectors:', e)
|
||||||
@@ -76,6 +105,24 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
|||||||
|
|
||||||
return matchesSearch
|
return matchesSearch
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Сброс на первую страницу при изменении поиска
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1)
|
||||||
|
}, [searchTerm])
|
||||||
|
|
||||||
|
// Пагинация
|
||||||
|
const totalPages = Math.ceil(filteredDetectors.length / itemsPerPage)
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage
|
||||||
|
const endIndex = startIndex + itemsPerPage
|
||||||
|
const currentDetectors = filteredDetectors.slice(startIndex, endIndex)
|
||||||
|
const paginationRange = getPaginationRange(currentPage, totalPages)
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page)
|
||||||
|
// Скролл наверх таблицы
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -84,6 +131,30 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Выбор количества элементов */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-400">Показывать:</span>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={itemsPerPage}
|
||||||
|
onChange={(e) => {
|
||||||
|
setItemsPerPage(Number(e.target.value))
|
||||||
|
setCurrentPage(1)
|
||||||
|
}}
|
||||||
|
className="bg-[#161824] text-white px-3 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none text-sm font-medium appearance-none pr-8"
|
||||||
|
style={{ fontFamily: 'Inter, sans-serif' }}
|
||||||
|
>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
</select>
|
||||||
|
<svg className="w-4 h-4 absolute right-2 top-1/2 transform -translate-y-1/2 pointer-events-none text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -106,28 +177,6 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-700">
|
<tr className="border-b border-gray-700">
|
||||||
<th className="text-left text-white font-medium py-3 w-12">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedDetectors.length === filteredDetectors.length && filteredDetectors.length > 0}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
filteredDetectors.forEach(detector => {
|
|
||||||
if (!selectedDetectors.includes(detector.detector_id)) {
|
|
||||||
onDetectorSelect(detector.detector_id, true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
filteredDetectors.forEach(detector => {
|
|
||||||
if (selectedDetectors.includes(detector.detector_id)) {
|
|
||||||
onDetectorSelect(detector.detector_id, false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th className="text-left text-white font-medium py-3">Детектор</th>
|
<th className="text-left text-white font-medium py-3">Детектор</th>
|
||||||
<th className="text-left text-white font-medium py-3">Статус</th>
|
<th className="text-left text-white font-medium py-3">Статус</th>
|
||||||
<th className="text-left text-white font-medium py-3">Местоположение</th>
|
<th className="text-left text-white font-medium py-3">Местоположение</th>
|
||||||
@@ -135,19 +184,11 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredDetectors.map((detector) => {
|
{currentDetectors.map((detector) => {
|
||||||
const isSelected = selectedDetectors.includes(detector.detector_id)
|
const isSelected = selectedDetectors.includes(detector.detector_id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={detector.detector_id} className="border-b border-gray-800">
|
<tr key={detector.detector_id} className="border-b border-gray-800">
|
||||||
<td className="py-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={(e) => onDetectorSelect(detector.detector_id, e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 text-white text-sm">{detector.name}</td>
|
<td className="py-3 text-white text-sm">{detector.name}</td>
|
||||||
<td className="py-3">
|
<td className="py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -183,9 +224,76 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Пагинация */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-6 flex flex-col items-center gap-4">
|
||||||
|
{/* Кнопки пагинации */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Кнопка "Предыдущая" */}
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
currentPage === 1
|
||||||
|
? 'text-gray-600 cursor-not-allowed'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-[#1E293B]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
← Предыдущая
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Номера страниц */}
|
||||||
|
{paginationRange.map((page, index) => {
|
||||||
|
if (page === '...') {
|
||||||
|
return (
|
||||||
|
<span key={`ellipsis-${index}`} className="px-3 py-2 text-gray-500">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNumber = page as number
|
||||||
|
const isActive = pageNumber === currentPage
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pageNumber}
|
||||||
|
onClick={() => handlePageChange(pageNumber)}
|
||||||
|
className={`min-w-[40px] px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-[#1E293B]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Кнопка "Следующая" */}
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
currentPage === totalPages
|
||||||
|
? 'text-gray-600 cursor-not-allowed'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-[#1E293B]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Следующая →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Счётчик */}
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Показано {startIndex + 1}-{Math.min(endIndex, filteredDetectors.length)} из {filteredDetectors.length} датчиков
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Статы детекторров*/}
|
{/* Статы детекторов */}
|
||||||
<div className="mt-6 grid grid-cols-4 gap-4">
|
<div className="mt-6 grid grid-cols-4 gap-4">
|
||||||
<div className="bg-[#161824] p-4 rounded-lg">
|
<div className="bg-[#161824] p-4 rounded-lg">
|
||||||
<div className="text-2xl font-bold text-white">{filteredDetectors.length}</div>
|
<div className="text-2xl font-bold text-white">{filteredDetectors.length}</div>
|
||||||
|
|||||||
299
frontend/components/alerts/DetectorList.tsx — копия 2
Normal file
299
frontend/components/alerts/DetectorList.tsx — копия 2
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import * as statusColors from '../../lib/statusColors'
|
||||||
|
|
||||||
|
interface Detector {
|
||||||
|
detector_id: number
|
||||||
|
name: string
|
||||||
|
location: string
|
||||||
|
status: string
|
||||||
|
object: string
|
||||||
|
floor: number
|
||||||
|
checked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawDetector {
|
||||||
|
detector_id: number
|
||||||
|
name: string
|
||||||
|
object: string
|
||||||
|
status: string
|
||||||
|
type: string
|
||||||
|
detector_type: string
|
||||||
|
location: string
|
||||||
|
floor: number
|
||||||
|
notifications: Array<{
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetectorListProps {
|
||||||
|
objectId?: string
|
||||||
|
selectedDetectors: number[]
|
||||||
|
onDetectorSelect: (detectorId: number, selected: boolean) => void
|
||||||
|
initialSearchTerm?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для генерации умного диапазона страниц
|
||||||
|
function getPaginationRange(currentPage: number, totalPages: number): (number | string)[] {
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
// Если страниц мало - показываем все
|
||||||
|
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Всегда показываем первые 3 и последние 3
|
||||||
|
const start = [1, 2, 3]
|
||||||
|
const end = [totalPages - 2, totalPages - 1, totalPages]
|
||||||
|
|
||||||
|
if (currentPage <= 4) {
|
||||||
|
// Начало: 1 2 3 4 5 ... 11 12 13
|
||||||
|
return [1, 2, 3, 4, 5, '...', ...end]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage >= totalPages - 3) {
|
||||||
|
// Конец: 1 2 3 ... 9 10 11 12 13
|
||||||
|
return [...start, '...', totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Середина: 1 2 3 ... 6 7 8 ... 11 12 13
|
||||||
|
return [...start, '...', currentPage - 1, currentPage, currentPage + 1, '...', ...end]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors, onDetectorSelect, initialSearchTerm = '' }) => {
|
||||||
|
const [detectors, setDetectors] = useState<Detector[]>([])
|
||||||
|
const [searchTerm, setSearchTerm] = useState<string>(initialSearchTerm)
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const itemsPerPage = 20
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDetectors = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/get-detectors', { cache: 'no-store' })
|
||||||
|
if (!res.ok) return
|
||||||
|
const payload = await res.json()
|
||||||
|
const detectorsData: Record<string, RawDetector> = payload?.data?.detectors ?? {}
|
||||||
|
const rawArray: RawDetector[] = Object.values(detectorsData).filter(
|
||||||
|
(detector) => (objectId ? detector.object === objectId : true)
|
||||||
|
)
|
||||||
|
const normalized: Detector[] = rawArray.map((d) => ({
|
||||||
|
detector_id: d.detector_id,
|
||||||
|
name: d.name,
|
||||||
|
location: d.location,
|
||||||
|
status: d.status,
|
||||||
|
object: d.object,
|
||||||
|
floor: d.floor,
|
||||||
|
checked: false,
|
||||||
|
}))
|
||||||
|
console.log('[DetectorList] Payload:', payload)
|
||||||
|
setDetectors(normalized)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load detectors:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadDetectors()
|
||||||
|
}, [objectId])
|
||||||
|
|
||||||
|
const filteredDetectors = detectors.filter(detector => {
|
||||||
|
const matchesSearch = searchTerm === '' || detector.detector_id.toString() === searchTerm
|
||||||
|
|
||||||
|
return matchesSearch
|
||||||
|
})
|
||||||
|
|
||||||
|
// Сброс на первую страницу при изменении поиска
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1)
|
||||||
|
}, [searchTerm])
|
||||||
|
|
||||||
|
// Пагинация
|
||||||
|
const totalPages = Math.ceil(filteredDetectors.length / itemsPerPage)
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage
|
||||||
|
const endIndex = startIndex + itemsPerPage
|
||||||
|
const currentDetectors = filteredDetectors.slice(startIndex, endIndex)
|
||||||
|
const paginationRange = getPaginationRange(currentPage, totalPages)
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page)
|
||||||
|
// Скролл наверх таблицы
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по ID детектора..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64 text-sm font-medium"
|
||||||
|
style={{ fontFamily: 'Inter, sans-serif' }}
|
||||||
|
/>
|
||||||
|
<svg className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Таблица детекторов */}
|
||||||
|
<div className="bg-[#161824] rounded-[20px] p-6">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-700">
|
||||||
|
<th className="text-left text-white font-medium py-3">Детектор</th>
|
||||||
|
<th className="text-left text-white font-medium py-3">Статус</th>
|
||||||
|
<th className="text-left text-white font-medium py-3">Местоположение</th>
|
||||||
|
<th className="text-left text-white font-medium py-3">Проверен</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{currentDetectors.map((detector) => {
|
||||||
|
const isSelected = selectedDetectors.includes(detector.detector_id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={detector.detector_id} className="border-b border-gray-800">
|
||||||
|
<td className="py-3 text-white text-sm">{detector.name}</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: detector.status }}
|
||||||
|
></div>
|
||||||
|
<span className="text-sm text-gray-300">
|
||||||
|
{detector.status === statusColors.STATUS_COLOR_CRITICAL
|
||||||
|
? 'Критическое'
|
||||||
|
: detector.status === statusColors.STATUS_COLOR_WARNING
|
||||||
|
? 'Предупреждение'
|
||||||
|
: 'Норма'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-gray-400 text-sm">{detector.location}</td>
|
||||||
|
<td className="py-3">
|
||||||
|
{detector.checked ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<svg className="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm text-green-500">Да</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-500">Нет</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Пагинация */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-6 flex flex-col items-center gap-4">
|
||||||
|
{/* Кнопки пагинации */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Кнопка "Предыдущая" */}
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
currentPage === 1
|
||||||
|
? 'text-gray-600 cursor-not-allowed'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-[#1E293B]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
← Предыдущая
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Номера страниц */}
|
||||||
|
{paginationRange.map((page, index) => {
|
||||||
|
if (page === '...') {
|
||||||
|
return (
|
||||||
|
<span key={`ellipsis-${index}`} className="px-3 py-2 text-gray-500">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNumber = page as number
|
||||||
|
const isActive = pageNumber === currentPage
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pageNumber}
|
||||||
|
onClick={() => handlePageChange(pageNumber)}
|
||||||
|
className={`min-w-[40px] px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-[#1E293B]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Кнопка "Следующая" */}
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
currentPage === totalPages
|
||||||
|
? 'text-gray-600 cursor-not-allowed'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-[#1E293B]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Следующая →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Счётчик */}
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Показано {startIndex + 1}-{Math.min(endIndex, filteredDetectors.length)} из {filteredDetectors.length} датчиков
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Статы детекторов */}
|
||||||
|
<div className="mt-6 grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-[#161824] p-4 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-white">{filteredDetectors.length}</div>
|
||||||
|
<div className="text-sm text-gray-400">Всего</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#161824] p-4 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-green-500">{filteredDetectors.filter(d => d.status === statusColors.STATUS_COLOR_NORMAL).length}</div>
|
||||||
|
<div className="text-sm text-gray-400">Норма</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#161824] p-4 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-orange-500">{filteredDetectors.filter(d => d.status === statusColors.STATUS_COLOR_WARNING).length}</div>
|
||||||
|
<div className="text-sm text-gray-400">Предупреждения</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#161824] p-4 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-red-500">{filteredDetectors.filter(d => d.status === statusColors.STATUS_COLOR_CRITICAL).length}</div>
|
||||||
|
<div className="text-sm text-gray-400">Критические</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredDetectors.length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-gray-400">Детекторы не найдены</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DetectorList
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
interface ChartDataPoint {
|
interface ChartDataPoint {
|
||||||
value: number
|
critical?: number
|
||||||
|
warning?: number
|
||||||
label?: string
|
label?: string
|
||||||
timestamp?: string
|
timestamp?: string
|
||||||
}
|
}
|
||||||
@@ -16,26 +17,37 @@ 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
|
||||||
|
|
||||||
const safeData = (Array.isArray(data) && data.length > 0)
|
const safeData = (Array.isArray(data) && data.length > 0)
|
||||||
? data
|
? data
|
||||||
: Array.from({ length: 7 }, () => ({ value: 0 }))
|
: Array.from({ length: 7 }, () => ({ critical: 0, warning: 0 }))
|
||||||
|
|
||||||
const maxVal = Math.max(...safeData.map(d => d.value || 0), 1)
|
const maxVal = Math.max(...safeData.map(d => Math.max(d.critical || 0, d.warning || 0)), 1)
|
||||||
const stepX = safeData.length > 1 ? plotWidth / (safeData.length - 1) : plotWidth
|
const stepX = safeData.length > 1 ? plotWidth / (safeData.length - 1) : plotWidth
|
||||||
|
|
||||||
const points = safeData.map((d, i) => {
|
// Точки для критических событий (красная линия)
|
||||||
|
const criticalPoints = safeData.map((d, i) => {
|
||||||
const x = margin.left + i * stepX
|
const x = margin.left + i * stepX
|
||||||
const y = baselineY - (Math.min(d.value || 0, maxVal) / maxVal) * plotHeight
|
const y = baselineY - (Math.min(d.critical || 0, maxVal) / maxVal) * plotHeight
|
||||||
return { x, y }
|
return { x, y }
|
||||||
})
|
})
|
||||||
|
|
||||||
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
// Точки для предупреждений (оранжевая линия)
|
||||||
const areaPath = `${linePath} L${width - margin.right},${baselineY} L${margin.left},${baselineY} Z`
|
const warningPoints = safeData.map((d, i) => {
|
||||||
|
const x = margin.left + i * stepX
|
||||||
|
const y = baselineY - (Math.min(d.warning || 0, maxVal) / maxVal) * plotHeight
|
||||||
|
return { x, y }
|
||||||
|
})
|
||||||
|
|
||||||
|
const criticalLinePath = criticalPoints.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
||||||
|
const criticalAreaPath = `${criticalLinePath} L${width - margin.right},${baselineY} L${margin.left},${baselineY} Z`
|
||||||
|
|
||||||
|
const warningLinePath = warningPoints.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
||||||
|
const warningAreaPath = `${warningLinePath} L${width - margin.right},${baselineY} L${margin.left},${baselineY} Z`
|
||||||
|
|
||||||
// Генерируем Y-оси метки
|
// Генерируем Y-оси метки
|
||||||
const ySteps = 4
|
const ySteps = 4
|
||||||
@@ -59,9 +71,13 @@ const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
|||||||
<div className={`w-full h-full ${className}`}>
|
<div className={`w-full h-full ${className}`}>
|
||||||
<svg className="w-full h-full" viewBox={`0 0 ${width} ${height}`}>
|
<svg className="w-full h-full" viewBox={`0 0 ${width} ${height}`}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="criticalGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor="rgb(37, 99, 235)" stopOpacity="0.3" />
|
<stop offset="0%" stopColor="#ef4444" stopOpacity="0.3" />
|
||||||
<stop offset="100%" stopColor="rgb(37, 99, 235)" stopOpacity="0" />
|
<stop offset="100%" stopColor="#ef4444" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="warningGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#fb923c" stopOpacity="0.3" />
|
||||||
|
<stop offset="100%" stopColor="#fb923c" stopOpacity="0" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
@@ -157,7 +173,7 @@ const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
|||||||
fontFamily="Arial, sans-serif"
|
fontFamily="Arial, sans-serif"
|
||||||
transform={`rotate(-90, 20, ${margin.top + plotHeight / 2})`}
|
transform={`rotate(-90, 20, ${margin.top + plotHeight / 2})`}
|
||||||
>
|
>
|
||||||
Значение
|
Количество
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
{/* Подпись оси X */}
|
{/* Подпись оси X */}
|
||||||
@@ -172,22 +188,52 @@ const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
|||||||
Время
|
Время
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
{/* График */}
|
{/* График предупреждений (оранжевый) - рисуем первым чтобы был на заднем плане */}
|
||||||
<path d={areaPath} fill="url(#areaGradient)" />
|
<path d={warningAreaPath} fill="url(#warningGradient)" />
|
||||||
<path d={linePath} stroke="rgb(37, 99, 235)" strokeWidth="2.5" fill="none" />
|
<path d={warningLinePath} stroke="#fb923c" strokeWidth="2.5" fill="none" />
|
||||||
|
|
||||||
{/* Точки данных */}
|
{/* Точки данных для предупреждений */}
|
||||||
{points.map((p, i) => (
|
{warningPoints.map((p, i) => (
|
||||||
<circle
|
<circle
|
||||||
key={i}
|
key={`warning-${i}`}
|
||||||
cx={p.x}
|
cx={p.x}
|
||||||
cy={p.y}
|
cy={p.y}
|
||||||
r="4"
|
r="4"
|
||||||
fill="rgb(37, 99, 235)"
|
fill="#fb923c"
|
||||||
stroke="rgb(15, 23, 42)"
|
stroke="rgb(15, 23, 42)"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* График критических событий (красный) - рисуем поверх */}
|
||||||
|
<path d={criticalAreaPath} fill="url(#criticalGradient)" />
|
||||||
|
<path d={criticalLinePath} stroke="#ef4444" strokeWidth="2.5" fill="none" />
|
||||||
|
|
||||||
|
{/* Точки данных для критических */}
|
||||||
|
{criticalPoints.map((p, i) => (
|
||||||
|
<circle
|
||||||
|
key={`critical-${i}`}
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r="4"
|
||||||
|
fill="#ef4444"
|
||||||
|
stroke="rgb(15, 23, 42)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Легенда - горизонтально над графиком */}
|
||||||
|
<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" />
|
||||||
|
<text x="18" y="10" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||||
|
Критические
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<circle cx="126" cy="6" r="4" fill="#fb923c" stroke="rgb(15, 23, 42)" strokeWidth="2" />
|
||||||
|
<text x="138" y="10" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||||
|
Предупреждения
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
196
frontend/components/dashboard/AreaChart.tsx — копия
Normal file
196
frontend/components/dashboard/AreaChart.tsx — копия
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface ChartDataPoint {
|
||||||
|
value: number
|
||||||
|
label?: string
|
||||||
|
timestamp?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AreaChartProps {
|
||||||
|
className?: string
|
||||||
|
data?: ChartDataPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
||||||
|
const width = 635
|
||||||
|
const height = 280
|
||||||
|
const margin = { top: 20, right: 30, bottom: 50, left: 60 }
|
||||||
|
const plotWidth = width - margin.left - margin.right
|
||||||
|
const plotHeight = height - margin.top - margin.bottom
|
||||||
|
const baselineY = margin.top + plotHeight
|
||||||
|
|
||||||
|
const safeData = (Array.isArray(data) && data.length > 0)
|
||||||
|
? data
|
||||||
|
: Array.from({ length: 7 }, () => ({ value: 0 }))
|
||||||
|
|
||||||
|
const maxVal = Math.max(...safeData.map(d => d.value || 0), 1)
|
||||||
|
const stepX = safeData.length > 1 ? plotWidth / (safeData.length - 1) : plotWidth
|
||||||
|
|
||||||
|
const points = safeData.map((d, i) => {
|
||||||
|
const x = margin.left + i * stepX
|
||||||
|
const y = baselineY - (Math.min(d.value || 0, maxVal) / maxVal) * plotHeight
|
||||||
|
return { x, y }
|
||||||
|
})
|
||||||
|
|
||||||
|
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
||||||
|
const areaPath = `${linePath} L${width - margin.right},${baselineY} L${margin.left},${baselineY} Z`
|
||||||
|
|
||||||
|
// Генерируем Y-оси метки
|
||||||
|
const ySteps = 4
|
||||||
|
const yLabels = Array.from({ length: ySteps + 1 }, (_, i) => {
|
||||||
|
const value = (maxVal / ySteps) * (ySteps - i)
|
||||||
|
const y = margin.top + (i * plotHeight) / ySteps
|
||||||
|
return { value: value.toFixed(1), y }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Генерируем X-оси метки (показываем каждую 2-ю или 3-ю точку)
|
||||||
|
const xLabelStep = Math.ceil(safeData.length / 5)
|
||||||
|
const xLabels = safeData
|
||||||
|
.map((d, i) => {
|
||||||
|
const x = margin.left + i * stepX
|
||||||
|
const label = d.label || d.timestamp || `${i + 1}`
|
||||||
|
return { label, x, index: i }
|
||||||
|
})
|
||||||
|
.filter((_, i) => i % xLabelStep === 0 || i === safeData.length - 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`w-full h-full ${className}`}>
|
||||||
|
<svg className="w-full h-full" viewBox={`0 0 ${width} ${height}`}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="rgb(37, 99, 235)" stopOpacity="0.3" />
|
||||||
|
<stop offset="100%" stopColor="rgb(37, 99, 235)" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Сетка Y */}
|
||||||
|
{yLabels.map((label, i) => (
|
||||||
|
<line
|
||||||
|
key={`grid-y-${i}`}
|
||||||
|
x1={margin.left}
|
||||||
|
y1={label.y}
|
||||||
|
x2={width - margin.right}
|
||||||
|
y2={label.y}
|
||||||
|
stroke="rgba(148, 163, 184, 0.2)"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeDasharray="4,4"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Ось X */}
|
||||||
|
<line
|
||||||
|
x1={margin.left}
|
||||||
|
y1={baselineY}
|
||||||
|
x2={width - margin.right}
|
||||||
|
y2={baselineY}
|
||||||
|
stroke="rgb(148, 163, 184)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Ось Y */}
|
||||||
|
<line
|
||||||
|
x1={margin.left}
|
||||||
|
y1={margin.top}
|
||||||
|
x2={margin.left}
|
||||||
|
y2={baselineY}
|
||||||
|
stroke="rgb(148, 163, 184)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Y-оси метки и подписи */}
|
||||||
|
{yLabels.map((label, i) => (
|
||||||
|
<g key={`y-label-${i}`}>
|
||||||
|
<line
|
||||||
|
x1={margin.left - 5}
|
||||||
|
y1={label.y}
|
||||||
|
x2={margin.left}
|
||||||
|
y2={label.y}
|
||||||
|
stroke="rgb(148, 163, 184)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={margin.left - 10}
|
||||||
|
y={label.y + 4}
|
||||||
|
textAnchor="end"
|
||||||
|
fontSize="12"
|
||||||
|
fill="rgb(148, 163, 184)"
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
>
|
||||||
|
{label.value}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* X-оси метки и подписи */}
|
||||||
|
{xLabels.map((label, i) => (
|
||||||
|
<g key={`x-label-${i}`}>
|
||||||
|
<line
|
||||||
|
x1={label.x}
|
||||||
|
y1={baselineY}
|
||||||
|
x2={label.x}
|
||||||
|
y2={baselineY + 5}
|
||||||
|
stroke="rgb(148, 163, 184)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={label.x}
|
||||||
|
y={baselineY + 20}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="11"
|
||||||
|
fill="rgb(148, 163, 184)"
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
>
|
||||||
|
{typeof label.label === 'string' ? label.label.substring(0, 10) : `${label.index + 1}`}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Подпись оси Y */}
|
||||||
|
<text
|
||||||
|
x={20}
|
||||||
|
y={margin.top + plotHeight / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="13"
|
||||||
|
fill="rgb(148, 163, 184)"
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
transform={`rotate(-90, 20, ${margin.top + plotHeight / 2})`}
|
||||||
|
>
|
||||||
|
Значение
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Подпись оси X */}
|
||||||
|
<text
|
||||||
|
x={margin.left + plotWidth / 2}
|
||||||
|
y={height - 10}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="13"
|
||||||
|
fill="rgb(148, 163, 184)"
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
>
|
||||||
|
Время
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* График */}
|
||||||
|
<path d={areaPath} fill="url(#areaGradient)" />
|
||||||
|
<path d={linePath} stroke="rgb(37, 99, 235)" strokeWidth="2.5" fill="none" />
|
||||||
|
|
||||||
|
{/* Точки данных */}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r="4"
|
||||||
|
fill="rgb(37, 99, 235)"
|
||||||
|
stroke="rgb(15, 23, 42)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AreaChart
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
interface ChartDataPoint {
|
interface ChartDataPoint {
|
||||||
value: number
|
critical?: number
|
||||||
|
warning?: number
|
||||||
label?: string
|
label?: string
|
||||||
color?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BarChartProps {
|
interface BarChartProps {
|
||||||
@@ -16,16 +16,20 @@ 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
|
||||||
|
|
||||||
const barData = (Array.isArray(data) && data.length > 0)
|
const barData = (Array.isArray(data) && data.length > 0)
|
||||||
? data.map(d => ({ value: d.value, label: d.label || '', color: d.color || 'rgb(37, 99, 235)' }))
|
? data.map(d => ({
|
||||||
: Array.from({ length: 12 }, (_, i) => ({ value: 0, label: `${i + 1}`, color: 'rgb(37, 99, 235)' }))
|
critical: d.critical || 0,
|
||||||
|
warning: d.warning || 0,
|
||||||
|
label: d.label || ''
|
||||||
|
}))
|
||||||
|
: Array.from({ length: 12 }, (_, i) => ({ critical: 0, warning: 0, label: `${i + 1}` }))
|
||||||
|
|
||||||
const maxVal = Math.max(...barData.map(b => b.value || 0), 1)
|
const maxVal = Math.max(...barData.map(b => (b.critical || 0) + (b.warning || 0)), 1)
|
||||||
|
|
||||||
// Генерируем Y-оси метки
|
// Генерируем Y-оси метки
|
||||||
const ySteps = 4
|
const ySteps = 4
|
||||||
@@ -141,7 +145,7 @@ const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
|||||||
fontFamily="Arial, sans-serif"
|
fontFamily="Arial, sans-serif"
|
||||||
transform={`rotate(-90, 20, ${margin.top + plotHeight / 2})`}
|
transform={`rotate(-90, 20, ${margin.top + plotHeight / 2})`}
|
||||||
>
|
>
|
||||||
Значение
|
Количество
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
{/* Подпись оси X */}
|
{/* Подпись оси X */}
|
||||||
@@ -156,42 +160,98 @@ const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
|||||||
Период
|
Период
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
{/* Столбцы */}
|
{/* Столбцы - сгруппированные по критическим и предупреждениям */}
|
||||||
{barData.map((bar, index) => {
|
{barData.map((bar, index) => {
|
||||||
const barWidth = Math.max(30, plotWidth / barData.length - 8)
|
const barWidth = Math.max(30, plotWidth / barData.length - 8)
|
||||||
const barSpacing = (plotWidth - barWidth * barData.length) / (barData.length - 1 || 1)
|
const barSpacing = (plotWidth - barWidth * barData.length) / (barData.length - 1 || 1)
|
||||||
const x = margin.left + index * (barWidth + barSpacing)
|
const groupX = margin.left + index * (barWidth + barSpacing)
|
||||||
const barHeight = (bar.value / maxVal) * plotHeight
|
|
||||||
const y = baselineY - barHeight
|
const totalValue = (bar.critical || 0) + (bar.warning || 0)
|
||||||
|
|
||||||
|
// Ширина каждого подстолбца
|
||||||
|
const subBarWidth = barWidth / 2 - 2
|
||||||
|
|
||||||
|
// Критический столбец (красный)
|
||||||
|
const criticalHeight = (bar.critical / maxVal) * plotHeight
|
||||||
|
const criticalY = baselineY - criticalHeight
|
||||||
|
|
||||||
|
// Предупреждение столбец (оранжевый)
|
||||||
|
const warningHeight = (bar.warning / maxVal) * plotHeight
|
||||||
|
const warningY = baselineY - warningHeight
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={`bar-${index}`}>
|
<g key={`bar-group-${index}`}>
|
||||||
<rect
|
{/* Критический столбец */}
|
||||||
x={x}
|
{bar.critical > 0 && (
|
||||||
y={y}
|
<>
|
||||||
width={barWidth}
|
<rect
|
||||||
height={barHeight}
|
x={groupX}
|
||||||
fill={bar.color}
|
y={criticalY}
|
||||||
rx="4"
|
width={subBarWidth}
|
||||||
ry="4"
|
height={criticalHeight}
|
||||||
opacity="0.9"
|
fill="#ef4444"
|
||||||
/>
|
rx="4"
|
||||||
{/* Тень для глубины */}
|
ry="4"
|
||||||
<rect
|
opacity="0.9"
|
||||||
x={x}
|
/>
|
||||||
y={y}
|
<rect
|
||||||
width={barWidth}
|
x={groupX}
|
||||||
height={barHeight}
|
y={criticalY}
|
||||||
fill="none"
|
width={subBarWidth}
|
||||||
stroke={bar.color}
|
height={criticalHeight}
|
||||||
strokeWidth="1"
|
fill="none"
|
||||||
rx="4"
|
stroke="#ef4444"
|
||||||
ry="4"
|
strokeWidth="1"
|
||||||
opacity="0.3"
|
rx="4"
|
||||||
/>
|
ry="4"
|
||||||
|
opacity="0.3"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Предупреждение столбец */}
|
||||||
|
{bar.warning > 0 && (
|
||||||
|
<>
|
||||||
|
<rect
|
||||||
|
x={groupX + subBarWidth + 4}
|
||||||
|
y={warningY}
|
||||||
|
width={subBarWidth}
|
||||||
|
height={warningHeight}
|
||||||
|
fill="#fb923c"
|
||||||
|
rx="4"
|
||||||
|
ry="4"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={groupX + subBarWidth + 4}
|
||||||
|
y={warningY}
|
||||||
|
width={subBarWidth}
|
||||||
|
height={warningHeight}
|
||||||
|
fill="none"
|
||||||
|
stroke="#fb923c"
|
||||||
|
strokeWidth="1"
|
||||||
|
rx="4"
|
||||||
|
ry="4"
|
||||||
|
opacity="0.3"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Легенда - горизонтально над графиком */}
|
||||||
|
<g transform={`translate(${margin.left + plotWidth / 2 - 120}, 10)`}>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<rect x="120" y="0" width="12" height="12" fill="#fb923c" rx="2" />
|
||||||
|
<text x="138" y="10" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||||
|
Предупреждения
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
200
frontend/components/dashboard/BarChart.tsx — копия
Normal file
200
frontend/components/dashboard/BarChart.tsx — копия
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface ChartDataPoint {
|
||||||
|
value: number
|
||||||
|
label?: string
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BarChartProps {
|
||||||
|
className?: string
|
||||||
|
data?: ChartDataPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
||||||
|
const width = 635
|
||||||
|
const height = 280
|
||||||
|
const margin = { top: 20, right: 30, bottom: 50, left: 60 }
|
||||||
|
const plotWidth = width - margin.left - margin.right
|
||||||
|
const plotHeight = height - margin.top - margin.bottom
|
||||||
|
const baselineY = margin.top + plotHeight
|
||||||
|
|
||||||
|
const barData = (Array.isArray(data) && data.length > 0)
|
||||||
|
? data.map(d => ({ value: d.value, label: d.label || '', color: d.color || 'rgb(37, 99, 235)' }))
|
||||||
|
: Array.from({ length: 12 }, (_, i) => ({ value: 0, label: `${i + 1}`, color: 'rgb(37, 99, 235)' }))
|
||||||
|
|
||||||
|
const maxVal = Math.max(...barData.map(b => b.value || 0), 1)
|
||||||
|
|
||||||
|
// Генерируем Y-оси метки
|
||||||
|
const ySteps = 4
|
||||||
|
const yLabels = Array.from({ length: ySteps + 1 }, (_, i) => {
|
||||||
|
const value = (maxVal / ySteps) * (ySteps - i)
|
||||||
|
const y = margin.top + (i * plotHeight) / ySteps
|
||||||
|
return { value: value.toFixed(1), y }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Генерируем X-оси метки (показываем каждую 2-ю или 3-ю)
|
||||||
|
const xLabelStep = Math.ceil(barData.length / 8)
|
||||||
|
const xLabels = barData
|
||||||
|
.map((d, i) => {
|
||||||
|
const barWidth = Math.max(30, plotWidth / barData.length - 8)
|
||||||
|
const barSpacing = (plotWidth - barWidth * barData.length) / (barData.length - 1 || 1)
|
||||||
|
const x = margin.left + i * (barWidth + barSpacing) + barWidth / 2
|
||||||
|
return { label: d.label || `${i + 1}`, x, index: i }
|
||||||
|
})
|
||||||
|
.filter((_, i) => i % xLabelStep === 0 || i === barData.length - 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`w-full h-full ${className}`}>
|
||||||
|
<svg className="w-full h-full" viewBox={`0 0 ${width} ${height}`}>
|
||||||
|
{/* Сетка Y */}
|
||||||
|
{yLabels.map((label, i) => (
|
||||||
|
<line
|
||||||
|
key={`grid-y-${i}`}
|
||||||
|
x1={margin.left}
|
||||||
|
y1={label.y}
|
||||||
|
x2={width - margin.right}
|
||||||
|
y2={label.y}
|
||||||
|
stroke="rgba(148, 163, 184, 0.2)"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeDasharray="4,4"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Ось X */}
|
||||||
|
<line
|
||||||
|
x1={margin.left}
|
||||||
|
y1={baselineY}
|
||||||
|
x2={width - margin.right}
|
||||||
|
y2={baselineY}
|
||||||
|
stroke="rgb(148, 163, 184)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Ось Y */}
|
||||||
|
<line
|
||||||
|
x1={margin.left}
|
||||||
|
y1={margin.top}
|
||||||
|
x2={margin.left}
|
||||||
|
y2={baselineY}
|
||||||
|
stroke="rgb(148, 163, 184)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Y-оси метки и подписи */}
|
||||||
|
{yLabels.map((label, i) => (
|
||||||
|
<g key={`y-label-${i}`}>
|
||||||
|
<line
|
||||||
|
x1={margin.left - 5}
|
||||||
|
y1={label.y}
|
||||||
|
x2={margin.left}
|
||||||
|
y2={label.y}
|
||||||
|
stroke="rgb(148, 163, 184)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={margin.left - 10}
|
||||||
|
y={label.y + 4}
|
||||||
|
textAnchor="end"
|
||||||
|
fontSize="12"
|
||||||
|
fill="rgb(148, 163, 184)"
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
>
|
||||||
|
{label.value}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* X-оси метки и подписи */}
|
||||||
|
{xLabels.map((label, i) => (
|
||||||
|
<g key={`x-label-${i}`}>
|
||||||
|
<line
|
||||||
|
x1={label.x}
|
||||||
|
y1={baselineY}
|
||||||
|
x2={label.x}
|
||||||
|
y2={baselineY + 5}
|
||||||
|
stroke="rgb(148, 163, 184)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={label.x}
|
||||||
|
y={baselineY + 20}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="11"
|
||||||
|
fill="rgb(148, 163, 184)"
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
>
|
||||||
|
{typeof label.label === 'string' ? label.label.substring(0, 8) : `${label.index + 1}`}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Подпись оси Y */}
|
||||||
|
<text
|
||||||
|
x={20}
|
||||||
|
y={margin.top + plotHeight / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="13"
|
||||||
|
fill="rgb(148, 163, 184)"
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
transform={`rotate(-90, 20, ${margin.top + plotHeight / 2})`}
|
||||||
|
>
|
||||||
|
Значение
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Подпись оси X */}
|
||||||
|
<text
|
||||||
|
x={margin.left + plotWidth / 2}
|
||||||
|
y={height - 10}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="13"
|
||||||
|
fill="rgb(148, 163, 184)"
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
>
|
||||||
|
Период
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Столбцы */}
|
||||||
|
{barData.map((bar, index) => {
|
||||||
|
const barWidth = Math.max(30, plotWidth / barData.length - 8)
|
||||||
|
const barSpacing = (plotWidth - barWidth * barData.length) / (barData.length - 1 || 1)
|
||||||
|
const x = margin.left + index * (barWidth + barSpacing)
|
||||||
|
const barHeight = (bar.value / maxVal) * plotHeight
|
||||||
|
const y = baselineY - barHeight
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`bar-${index}`}>
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={barWidth}
|
||||||
|
height={barHeight}
|
||||||
|
fill={bar.color}
|
||||||
|
rx="4"
|
||||||
|
ry="4"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
{/* Тень для глубины */}
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={barWidth}
|
||||||
|
height={barHeight}
|
||||||
|
fill="none"
|
||||||
|
stroke={bar.color}
|
||||||
|
strokeWidth="1"
|
||||||
|
rx="4"
|
||||||
|
ry="4"
|
||||||
|
opacity="0.3"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BarChart
|
||||||
@@ -8,11 +8,11 @@ import useNavigationStore from '../../app/store/navigationStore'
|
|||||||
import ChartCard from './ChartCard'
|
import ChartCard from './ChartCard'
|
||||||
import AreaChart from './AreaChart'
|
import AreaChart from './AreaChart'
|
||||||
import BarChart from './BarChart'
|
import BarChart from './BarChart'
|
||||||
import { aggregateChartDataByDays } from '../../lib/chartDataAggregator'
|
import { aggregateChartDataByDays, aggregateAlertsBySeverity } from '../../lib/chartDataAggregator'
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications } = useNavigationStore()
|
const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications, navigateToSensor } = useNavigationStore()
|
||||||
const objectTitle = currentObject?.title
|
const objectTitle = currentObject?.title
|
||||||
|
|
||||||
const [dashboardAlerts, setDashboardAlerts] = useState<any[]>([])
|
const [dashboardAlerts, setDashboardAlerts] = useState<any[]>([])
|
||||||
@@ -119,6 +119,27 @@ const Dashboard: React.FC = () => {
|
|||||||
setCurrentSubmenu(null)
|
setCurrentSubmenu(null)
|
||||||
router.push('/navigation')
|
router.push('/navigation')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleGoTo3D = async (alert: any, viewType: 'building' | 'floor') => {
|
||||||
|
// Используем alert.name как идентификатор датчика (например, "GA-11")
|
||||||
|
const sensorId = alert.serial_number || alert.name
|
||||||
|
|
||||||
|
if (!sensorId) {
|
||||||
|
console.warn('[Dashboard] Alert missing sensor identifier:', alert)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await navigateToSensor(
|
||||||
|
sensorId,
|
||||||
|
alert.floor || null,
|
||||||
|
viewType
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// Переходим на страницу навигации с параметрами focusSensorId и modelPath
|
||||||
|
router.push(`/navigation?focusSensorId=${encodeURIComponent(result.sensorSerialNumber)}&modelPath=${encodeURIComponent(result.modelPath)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSensorTypeChange = (sensorType: string) => {
|
const handleSensorTypeChange = (sensorType: string) => {
|
||||||
setSelectedSensorType(sensorType)
|
setSelectedSensorType(sensorType)
|
||||||
@@ -132,10 +153,10 @@ const Dashboard: React.FC = () => {
|
|||||||
setSelectedTablePeriod(period)
|
setSelectedTablePeriod(period)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Агрегируем данные графика в зависимости от периода
|
// Агрегируем данные графика в зависимости от периода с разделением по severity
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
return aggregateChartDataByDays(rawChartData, selectedChartPeriod)
|
return aggregateAlertsBySeverity(dashboardAlerts, selectedChartPeriod)
|
||||||
}, [rawChartData, selectedChartPeriod])
|
}, [dashboardAlerts, selectedChartPeriod])
|
||||||
|
|
||||||
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
|
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
|
||||||
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
|
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
|
||||||
@@ -228,7 +249,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<ChartCard
|
<ChartCard
|
||||||
title="Статистика"
|
title="Статистика"
|
||||||
>
|
>
|
||||||
<BarChart data={chartData?.map((d: any) => ({ value: d.value, label: d.label }))} />
|
<BarChart data={chartData} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,6 +287,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Серьезность</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Серьезность</th>
|
||||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Дата</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Дата</th>
|
||||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Решен</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Решен</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-center text-white text-sm py-3">3D Вид</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -287,6 +309,28 @@ const Dashboard: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleGoTo3D(alert, 'building')}
|
||||||
|
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
|
||||||
|
title="Показать на общей модели"
|
||||||
|
>
|
||||||
|
<img src="/icons/Building3D.png" alt="Здание" className="w-5 h-5 opacity-70 group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleGoTo3D(alert, 'floor')}
|
||||||
|
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
|
||||||
|
title="Показать на этаже"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/icons/Floor3D.png"
|
||||||
|
alt="Этаж"
|
||||||
|
className="w-5 h-5 opacity-70 group-hover:opacity-100"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
368
frontend/components/dashboard/Dashboard.tsx — копия 2
Normal file
368
frontend/components/dashboard/Dashboard.tsx — копия 2
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Sidebar from '../ui/Sidebar'
|
||||||
|
import AnimatedBackground from '../ui/AnimatedBackground'
|
||||||
|
import useNavigationStore from '../../app/store/navigationStore'
|
||||||
|
import ChartCard from './ChartCard'
|
||||||
|
import AreaChart from './AreaChart'
|
||||||
|
import BarChart from './BarChart'
|
||||||
|
import { aggregateChartDataByDays, aggregateAlertsBySeverity } from '../../lib/chartDataAggregator'
|
||||||
|
|
||||||
|
const Dashboard: React.FC = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications, navigateToSensor } = useNavigationStore()
|
||||||
|
const objectTitle = currentObject?.title
|
||||||
|
|
||||||
|
const [dashboardAlerts, setDashboardAlerts] = useState<any[]>([])
|
||||||
|
const [rawChartData, setRawChartData] = useState<{ timestamp: string; value: number }[]>([])
|
||||||
|
const [sensorTypes] = useState<Array<{code: string, name: string}>>([
|
||||||
|
{ code: '', name: 'Все датчики' },
|
||||||
|
{ code: 'GA', name: 'Инклинометр' },
|
||||||
|
{ code: 'PE', name: 'Танзометр' },
|
||||||
|
{ code: 'GLE', name: 'Гидроуровень' }
|
||||||
|
])
|
||||||
|
const [selectedSensorType, setSelectedSensorType] = useState<string>('')
|
||||||
|
const [selectedChartPeriod, setSelectedChartPeriod] = useState<string>('168')
|
||||||
|
const [selectedTablePeriod, setSelectedTablePeriod] = useState<string>('168')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDashboard = async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.append('time_period', selectedChartPeriod)
|
||||||
|
|
||||||
|
const res = await fetch(`/api/get-dashboard?${params.toString()}`, { cache: 'no-store' })
|
||||||
|
if (!res.ok) return
|
||||||
|
const payload = await res.json()
|
||||||
|
console.log('[Dashboard] GET /api/get-dashboard', { status: res.status, payload })
|
||||||
|
|
||||||
|
let tableData = payload?.data?.table_data ?? []
|
||||||
|
tableData = Array.isArray(tableData) ? tableData : []
|
||||||
|
|
||||||
|
if (objectTitle) {
|
||||||
|
tableData = tableData.filter((a: any) => a.object === objectTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSensorType && selectedSensorType !== '') {
|
||||||
|
tableData = tableData.filter((a: any) => {
|
||||||
|
return a.detector_type?.toLowerCase() === selectedSensorType.toLowerCase()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setDashboardAlerts(tableData as any[])
|
||||||
|
|
||||||
|
const cd = Array.isArray(payload?.data?.chart_data) ? payload.data.chart_data : []
|
||||||
|
setRawChartData(cd as any[])
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load dashboard:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadDashboard()
|
||||||
|
}, [objectTitle, selectedChartPeriod, selectedSensorType])
|
||||||
|
|
||||||
|
// Отдельный эффект для загрузки таблицы по выбранному периоду
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTableData = async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.append('time_period', selectedTablePeriod)
|
||||||
|
|
||||||
|
const res = await fetch(`/api/get-dashboard?${params.toString()}`, { cache: 'no-store' })
|
||||||
|
if (!res.ok) return
|
||||||
|
const payload = await res.json()
|
||||||
|
console.log('[Dashboard] GET /api/get-dashboard (table)', { status: res.status, payload })
|
||||||
|
|
||||||
|
let tableData = payload?.data?.table_data ?? []
|
||||||
|
tableData = Array.isArray(tableData) ? tableData : []
|
||||||
|
|
||||||
|
if (objectTitle) {
|
||||||
|
tableData = tableData.filter((a: any) => a.object === objectTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSensorType && selectedSensorType !== '') {
|
||||||
|
tableData = tableData.filter((a: any) => {
|
||||||
|
return a.detector_type?.toLowerCase() === selectedSensorType.toLowerCase()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setDashboardAlerts(tableData as any[])
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load table data:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadTableData()
|
||||||
|
}, [objectTitle, selectedTablePeriod, selectedSensorType])
|
||||||
|
|
||||||
|
const handleBackClick = () => {
|
||||||
|
router.push('/objects')
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredAlerts = dashboardAlerts.filter((alert: any) => {
|
||||||
|
if (selectedSensorType === '') return true
|
||||||
|
return alert.detector_type?.toLowerCase() === selectedSensorType.toLowerCase()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Статусы
|
||||||
|
const statusCounts = filteredAlerts.reduce((acc: { critical: number; warning: number; normal: number }, a: any) => {
|
||||||
|
if (a.severity === 'critical') acc.critical++
|
||||||
|
else if (a.severity === 'warning') acc.warning++
|
||||||
|
else acc.normal++
|
||||||
|
return acc
|
||||||
|
}, { critical: 0, warning: 0, normal: 0 })
|
||||||
|
|
||||||
|
const handleNavigationClick = () => {
|
||||||
|
closeMonitoring()
|
||||||
|
closeFloorNavigation()
|
||||||
|
closeNotifications()
|
||||||
|
setCurrentSubmenu(null)
|
||||||
|
router.push('/navigation')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGoTo3D = async (alert: any, viewType: 'building' | 'floor') => {
|
||||||
|
// Используем alert.name как идентификатор датчика (например, "GA-11")
|
||||||
|
const sensorId = alert.serial_number || alert.name
|
||||||
|
|
||||||
|
if (!sensorId) {
|
||||||
|
console.warn('[Dashboard] Alert missing sensor identifier:', alert)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensorSerialNumber = await navigateToSensor(
|
||||||
|
sensorId,
|
||||||
|
alert.floor || null,
|
||||||
|
viewType
|
||||||
|
)
|
||||||
|
|
||||||
|
if (sensorSerialNumber) {
|
||||||
|
// Переходим на страницу навигации с параметром focusSensorId
|
||||||
|
router.push(`/navigation?focusSensorId=${encodeURIComponent(sensorSerialNumber)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSensorTypeChange = (sensorType: string) => {
|
||||||
|
setSelectedSensorType(sensorType)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChartPeriodChange = (period: string) => {
|
||||||
|
setSelectedChartPeriod(period)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTablePeriodChange = (period: string) => {
|
||||||
|
setSelectedTablePeriod(period)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Агрегируем данные графика в зависимости от периода с разделением по severity
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
return aggregateAlertsBySeverity(dashboardAlerts, selectedChartPeriod)
|
||||||
|
}, [dashboardAlerts, selectedChartPeriod])
|
||||||
|
|
||||||
|
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
|
||||||
|
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-20">
|
||||||
|
<Sidebar
|
||||||
|
activeItem={1} // Dashboard
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex-1 flex flex-col">
|
||||||
|
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
|
||||||
|
<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>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 p-6 overflow-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 style={interSemiboldStyle} className="text-white text-2xl mb-6">{objectTitle || 'Объект'}</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={selectedSensorType}
|
||||||
|
onChange={(e) => handleSensorTypeChange(e.target.value)}
|
||||||
|
className="flex items-center gap-6 rounded-[10px] px-4 py-[18px] bg-[rgb(22,24,36)] text-white appearance-none pr-8"
|
||||||
|
>
|
||||||
|
{sensorTypes.map((type) => (
|
||||||
|
<option key={type.code} value={type.code}>
|
||||||
|
{type.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<svg className="w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 ml-auto">
|
||||||
|
<button
|
||||||
|
onClick={handleNavigationClick}
|
||||||
|
className="rounded-[10px] px-4 py-[18px] bg-gray-600 text-gray-300 hover:bg-[rgb(22,24,36)] hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">Навигация</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={selectedChartPeriod}
|
||||||
|
onChange={(e) => handleChartPeriodChange(e.target.value)}
|
||||||
|
className="flex items-center gap-2 bg-[rgb(22,24,36)] rounded-lg px-3 py-2 text-white appearance-none pr-8"
|
||||||
|
>
|
||||||
|
<option value="24">День</option>
|
||||||
|
<option value="72">3 дня</option>
|
||||||
|
<option value="168">Неделя</option>
|
||||||
|
<option value="720">Месяц</option>
|
||||||
|
</select>
|
||||||
|
<svg className="w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Карты-графики */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px]">
|
||||||
|
<ChartCard
|
||||||
|
title="Показатель"
|
||||||
|
>
|
||||||
|
<AreaChart data={chartData} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
title="Статистика"
|
||||||
|
>
|
||||||
|
<BarChart data={chartData} />
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список детекторов */}
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 style={interSemiboldStyle} className="text-white text-2xl">Тренды</h2>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={selectedTablePeriod}
|
||||||
|
onChange={(e) => handleTablePeriodChange(e.target.value)}
|
||||||
|
className="bg-[#161824] rounded-lg px-3 py-2 flex items-center gap-2 text-white appearance-none pr-8"
|
||||||
|
>
|
||||||
|
<option value="24">День</option>
|
||||||
|
<option value="72">3 дня</option>
|
||||||
|
<option value="168">Неделя</option>
|
||||||
|
<option value="720">Месяц</option>
|
||||||
|
</select>
|
||||||
|
<svg className="w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Таблица */}
|
||||||
|
<div className="bg-[#161824] rounded-[20px] p-6">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-700">
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Серьезность</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Дата</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Решен</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-center text-white text-sm py-3">3D Вид</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredAlerts.map((alert: any) => (
|
||||||
|
<tr key={alert.id} className="border-b border-gray-800">
|
||||||
|
<td style={interRegularStyle} className="py-3 text-white text-sm">{alert.name}</td>
|
||||||
|
<td style={interRegularStyle} className="py-3 text-gray-300 text-sm">{alert.message}</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<span style={interRegularStyle} className={`text-sm ${alert.severity === 'critical' ? 'text-red-500' : alert.severity === 'warning' ? 'text-orange-500' : 'text-green-500'}`}>
|
||||||
|
{alert.severity === 'critical' ? 'Критическое' : alert.severity === 'warning' ? 'Предупреждение' : 'Норма'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={interRegularStyle} className="py-3 text-gray-400 text-sm">{new Date(alert.created_at).toLocaleString()}</td>
|
||||||
|
<td className="py-3">
|
||||||
|
{alert.resolved ? (
|
||||||
|
<span style={interRegularStyle} className="text-sm text-green-500">Да</span>
|
||||||
|
) : (
|
||||||
|
<span style={interRegularStyle} className="text-sm text-gray-500">Нет</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleGoTo3D(alert, 'building')}
|
||||||
|
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
|
||||||
|
title="Показать на общей модели"
|
||||||
|
>
|
||||||
|
<img src="/icons/Building3D.png" alt="Здание" className="w-5 h-5 opacity-70 group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleGoTo3D(alert, 'floor')}
|
||||||
|
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
|
||||||
|
title="Показать на этаже"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/icons/Floor3D.png"
|
||||||
|
alt="Этаж"
|
||||||
|
className="w-5 h-5 opacity-70 group-hover:opacity-100"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Статистика */}
|
||||||
|
<div className="mt-6 grid grid-cols-4 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div style={interSemiboldStyle} className="text-2xl text-white">{filteredAlerts.length}</div>
|
||||||
|
<div style={interRegularStyle} className="text-sm text-gray-400">Всего</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div style={interSemiboldStyle} className="text-2xl text-green-500">{statusCounts.normal}</div>
|
||||||
|
<div style={interRegularStyle} className="text-sm text-gray-400">Норма</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div style={interSemiboldStyle} className="text-2xl text-orange-500">{statusCounts.warning}</div>
|
||||||
|
<div style={interRegularStyle} className="text-sm text-gray-400">Предупреждения</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div style={interSemiboldStyle} className="text-2xl text-red-500">{statusCounts.critical}</div>
|
||||||
|
<div style={interRegularStyle} className="text-sm text-gray-400">Критические</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Dashboard
|
||||||
317
frontend/components/model/Canvas2DPlan.tsx
Normal file
317
frontend/components/model/Canvas2DPlan.tsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
import { AbstractMesh, Vector3 } from '@babylonjs/core'
|
||||||
|
|
||||||
|
interface Canvas2DPlanProps {
|
||||||
|
meshes: AbstractMesh[]
|
||||||
|
sensorStatusMap: Record<string, string>
|
||||||
|
onClose: () => void
|
||||||
|
onSensorClick?: (sensorId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Sensor2D {
|
||||||
|
id: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Canvas2DPlan: React.FC<Canvas2DPlanProps> = ({
|
||||||
|
meshes,
|
||||||
|
sensorStatusMap,
|
||||||
|
onClose,
|
||||||
|
onSensorClick,
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const [sensors, setSensors] = useState<Sensor2D[]>([])
|
||||||
|
const [hoveredSensor, setHoveredSensor] = useState<string | null>(null)
|
||||||
|
const [scale, setScale] = useState(10)
|
||||||
|
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
||||||
|
|
||||||
|
// Извлечение датчиков из mesh'ей
|
||||||
|
useEffect(() => {
|
||||||
|
const extractedSensors: Sensor2D[] = []
|
||||||
|
|
||||||
|
console.log('[Canvas2DPlan] Extracting sensors from meshes:', meshes.length)
|
||||||
|
console.log('[Canvas2DPlan] sensorStatusMap:', sensorStatusMap)
|
||||||
|
|
||||||
|
let meshesWithMetadata = 0
|
||||||
|
let meshesWithSensorID = 0
|
||||||
|
let meshesInStatusMap = 0
|
||||||
|
|
||||||
|
meshes.forEach((mesh, index) => {
|
||||||
|
if (mesh.metadata) {
|
||||||
|
meshesWithMetadata++
|
||||||
|
if (index < 3) {
|
||||||
|
console.log(`[Canvas2DPlan] Sample mesh[${index}] metadata:`, mesh.metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensorId = mesh.metadata?.Sensor_ID
|
||||||
|
if (sensorId) {
|
||||||
|
meshesWithSensorID++
|
||||||
|
if (index < 3) {
|
||||||
|
console.log(`[Canvas2DPlan] Sample mesh[${index}] Sensor_ID:`, sensorId, 'in map?', !!sensorStatusMap[sensorId])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sensorId && sensorStatusMap[sensorId]) {
|
||||||
|
meshesInStatusMap++
|
||||||
|
const position = mesh.getAbsolutePosition()
|
||||||
|
extractedSensors.push({
|
||||||
|
id: sensorId,
|
||||||
|
x: position.x,
|
||||||
|
y: position.z, // Используем Z как Y для вида сверху
|
||||||
|
status: sensorStatusMap[sensorId],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[Canvas2DPlan] Meshes with metadata:', meshesWithMetadata)
|
||||||
|
console.log('[Canvas2DPlan] Meshes with Sensor_ID:', meshesWithSensorID)
|
||||||
|
console.log('[Canvas2DPlan] Meshes in statusMap:', meshesInStatusMap)
|
||||||
|
|
||||||
|
console.log('[Canvas2DPlan] Extracted sensors:', extractedSensors.length, extractedSensors)
|
||||||
|
|
||||||
|
setSensors(extractedSensors)
|
||||||
|
|
||||||
|
// Автоматическое центрирование
|
||||||
|
if (extractedSensors.length > 0 && canvasRef.current) {
|
||||||
|
const minX = Math.min(...extractedSensors.map((s) => s.x))
|
||||||
|
const maxX = Math.max(...extractedSensors.map((s) => s.x))
|
||||||
|
const minY = Math.min(...extractedSensors.map((s) => s.y))
|
||||||
|
const maxY = Math.max(...extractedSensors.map((s) => s.y))
|
||||||
|
|
||||||
|
const centerX = (minX + maxX) / 2
|
||||||
|
const centerY = (minY + maxY) / 2
|
||||||
|
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
setOffset({
|
||||||
|
x: canvas.width / 2 - centerX * scale,
|
||||||
|
y: canvas.height / 2 - centerY * scale,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [meshes, sensorStatusMap, scale])
|
||||||
|
|
||||||
|
// Рендеринг canvas
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Очистка
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Фон
|
||||||
|
ctx.fillStyle = '#0e111a'
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Сетка
|
||||||
|
ctx.strokeStyle = '#1a1d2e'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
const gridSize = 50
|
||||||
|
for (let x = 0; x < canvas.width; x += gridSize) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, 0)
|
||||||
|
ctx.lineTo(x, canvas.height)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
for (let y = 0; y < canvas.height; y += gridSize) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(0, y)
|
||||||
|
ctx.lineTo(canvas.width, y)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рисуем датчики
|
||||||
|
sensors.forEach((sensor) => {
|
||||||
|
const x = sensor.x * scale + offset.x
|
||||||
|
const y = sensor.y * scale + offset.y
|
||||||
|
|
||||||
|
// Определяем цвет по статусу
|
||||||
|
let color = '#6b7280' // gray
|
||||||
|
if (sensor.status === 'critical') color = '#ef4444' // red
|
||||||
|
else if (sensor.status === 'warning') color = '#f59e0b' // amber
|
||||||
|
else if (sensor.status === 'normal') color = '#10b981' // green
|
||||||
|
|
||||||
|
// Внешний круг (подсветка при hover)
|
||||||
|
if (hoveredSensor === sensor.id) {
|
||||||
|
ctx.fillStyle = color + '40'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(x, y, 20, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Основной круг датчика
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(x, y, 8, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
// Обводка
|
||||||
|
ctx.strokeStyle = '#ffffff'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// Подпись
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.font = '12px Inter, sans-serif'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(sensor.id, x, y - 15)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Легенда
|
||||||
|
const legendX = 20
|
||||||
|
const legendY = canvas.height - 80
|
||||||
|
ctx.fillStyle = '#161824cc'
|
||||||
|
ctx.fillRect(legendX - 10, legendY - 10, 180, 70)
|
||||||
|
|
||||||
|
const statuses = [
|
||||||
|
{ label: 'Критический', color: '#ef4444' },
|
||||||
|
{ label: 'Предупреждение', color: '#f59e0b' },
|
||||||
|
{ label: 'Нормальный', color: '#10b981' },
|
||||||
|
]
|
||||||
|
|
||||||
|
statuses.forEach((status, index) => {
|
||||||
|
const y = legendY + index * 20
|
||||||
|
ctx.fillStyle = status.color
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(legendX, y, 6, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.font = '12px Inter, sans-serif'
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
ctx.fillText(status.label, legendX + 15, y + 4)
|
||||||
|
})
|
||||||
|
}, [sensors, scale, offset, hoveredSensor])
|
||||||
|
|
||||||
|
// Обработка клика
|
||||||
|
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const clickX = e.clientX - rect.left
|
||||||
|
const clickY = e.clientY - rect.top
|
||||||
|
|
||||||
|
// Проверяем клик по датчику
|
||||||
|
for (const sensor of sensors) {
|
||||||
|
const x = sensor.x * scale + offset.x
|
||||||
|
const y = sensor.y * scale + offset.y
|
||||||
|
const distance = Math.sqrt((clickX - x) ** 2 + (clickY - y) ** 2)
|
||||||
|
|
||||||
|
if (distance <= 10) {
|
||||||
|
onSensorClick?.(sensor.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка hover
|
||||||
|
const handleCanvasMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
if (isDragging) {
|
||||||
|
const dx = e.clientX - dragStart.x
|
||||||
|
const dy = e.clientY - dragStart.y
|
||||||
|
setOffset((prev) => ({ x: prev.x + dx, y: prev.y + dy }))
|
||||||
|
setDragStart({ x: e.clientX, y: e.clientY })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const mouseX = e.clientX - rect.left
|
||||||
|
const mouseY = e.clientY - rect.top
|
||||||
|
|
||||||
|
let foundSensor: string | null = null
|
||||||
|
for (const sensor of sensors) {
|
||||||
|
const x = sensor.x * scale + offset.x
|
||||||
|
const y = sensor.y * scale + offset.y
|
||||||
|
const distance = Math.sqrt((mouseX - x) ** 2 + (mouseY - y) ** 2)
|
||||||
|
|
||||||
|
if (distance <= 10) {
|
||||||
|
foundSensor = sensor.id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHoveredSensor(foundSensor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка zoom
|
||||||
|
const handleWheel = (e: React.WheelEvent<HTMLCanvasElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
||||||
|
setScale((prev) => Math.max(1, Math.min(50, prev * delta)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка drag
|
||||||
|
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
setIsDragging(true)
|
||||||
|
setDragStart({ x: e.clientX, y: e.clientY })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] bg-black/80 flex items-center justify-center p-4">
|
||||||
|
<div className="relative bg-[#161824] rounded-lg shadow-2xl border border-white/10 p-6 w-full h-full max-w-[1400px] max-h-[900px] flex flex-col">
|
||||||
|
{/* Заголовок */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-white">2D План-схема</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Canvas */}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={1200}
|
||||||
|
height={700}
|
||||||
|
onClick={handleCanvasClick}
|
||||||
|
onMouseMove={handleCanvasMove}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
className="border border-white/10 rounded cursor-move w-full h-full"
|
||||||
|
style={{ cursor: isDragging ? 'grabbing' : 'grab', maxHeight: '700px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Подсказка */}
|
||||||
|
<div className="mt-4 text-sm text-gray-400 text-center">
|
||||||
|
<p>Колесико мыши - масштаб | Перетаскивание - перемещение | Клик по датчику - подробности</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Canvas2DPlan
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
PointerEventTypes,
|
PointerEventTypes,
|
||||||
PointerInfo,
|
PointerInfo,
|
||||||
Matrix,
|
Matrix,
|
||||||
|
Ray,
|
||||||
} from '@babylonjs/core'
|
} from '@babylonjs/core'
|
||||||
import '@babylonjs/loaders'
|
import '@babylonjs/loaders'
|
||||||
|
|
||||||
@@ -68,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)
|
||||||
@@ -233,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) {
|
||||||
@@ -340,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}`
|
||||||
@@ -362,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)
|
||||||
@@ -564,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
|
||||||
@@ -602,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!')
|
||||||
@@ -663,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)
|
||||||
|
|
||||||
@@ -689,16 +708,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,
|
||||||
@@ -759,7 +827,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
}, [modelReady, isSensorSelectionEnabled, onSensorPick])
|
}, [modelReady, isSensorSelectionEnabled, onSensorPick])
|
||||||
|
|
||||||
const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => {
|
const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => {
|
||||||
if (!sceneRef.current || !mesh) return null
|
if (!sceneRef.current || !mesh || !canvasRef.current) return null
|
||||||
const scene = sceneRef.current
|
const scene = sceneRef.current
|
||||||
try {
|
try {
|
||||||
const bbox = (typeof mesh.getHierarchyBoundingVectors === 'function')
|
const bbox = (typeof mesh.getHierarchyBoundingVectors === 'function')
|
||||||
@@ -773,7 +841,13 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
const projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport)
|
const projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport)
|
||||||
if (!projected) return null
|
if (!projected) return null
|
||||||
|
|
||||||
return { left: projected.x, top: projected.y }
|
// Позиционируем тултип слева от датчика
|
||||||
|
// Учитываем ширину тултипа (max-w-[400px]) + отступ 50px от датчика
|
||||||
|
const tooltipWidth = 400 // Максимальная ширина тултипа из DetectorMenu
|
||||||
|
const gapFromSensor = 50 // Отступ между правым краем тултипа и датчиком
|
||||||
|
const leftOffset = -(tooltipWidth + gapFromSensor) // Смещение влево от датчика
|
||||||
|
|
||||||
|
return { left: projected.x + leftOffset, top: projected.y }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ModelViewer] Error computing overlay position:', error)
|
console.error('[ModelViewer] Error computing overlay position:', error)
|
||||||
return null
|
return null
|
||||||
@@ -866,6 +940,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
|
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
|
||||||
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
|
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* UPDATED: Interactive overlay circles with hover effects */}
|
{/* UPDATED: Interactive overlay circles with hover effects */}
|
||||||
@@ -911,4 +986,4 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ModelViewer
|
export default React.memo(ModelViewer)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
983
frontend/components/model/ModelViewer.tsx — копия 3
Normal file
983
frontend/components/model/ModelViewer.tsx — копия 3
Normal file
@@ -0,0 +1,983 @@
|
|||||||
|
'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, sensorStatusMap)
|
||||||
|
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, sensorStatusMap)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сначала найдём ВСЕ датчики в 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] 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)
|
||||||
|
|
||||||
|
// Найдём датчики которые есть в sensorStatusMap но НЕТ в 3D модели
|
||||||
|
if (sensorStatusMap) {
|
||||||
|
const missingInModel = Object.keys(sensorStatusMap).filter(id => !allSensorIdsInModel.includes(id))
|
||||||
|
if (missingInModel.length > 0) {
|
||||||
|
console.warn('[ModelViewer] Sensors in sensorStatusMap but MISSING in 3D model:', missingInModel.length, missingInModel.slice(0, 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sensorMeshes.length === 0) {
|
||||||
|
console.warn('[ModelViewer] No sensor meshes found in 3D model!')
|
||||||
|
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, sensorStatusMap)
|
||||||
|
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 React.memo(ModelViewer)
|
||||||
983
frontend/components/model/ModelViewer.tsx — копия 4
Normal file
983
frontend/components/model/ModelViewer.tsx — копия 4
Normal file
@@ -0,0 +1,983 @@
|
|||||||
|
'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, sensorStatusMap)
|
||||||
|
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, sensorStatusMap)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сначала найдём ВСЕ датчики в 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] 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)
|
||||||
|
|
||||||
|
// Найдём датчики которые есть в sensorStatusMap но НЕТ в 3D модели
|
||||||
|
if (sensorStatusMap) {
|
||||||
|
const missingInModel = Object.keys(sensorStatusMap).filter(id => !allSensorIdsInModel.includes(id))
|
||||||
|
if (missingInModel.length > 0) {
|
||||||
|
console.warn('[ModelViewer] Sensors in sensorStatusMap but MISSING in 3D model:', missingInModel.length, missingInModel.slice(0, 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sensorMeshes.length === 0) {
|
||||||
|
console.warn('[ModelViewer] No sensor meshes found in 3D model!')
|
||||||
|
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, sensorStatusMap)
|
||||||
|
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 React.memo(ModelViewer)
|
||||||
@@ -47,50 +47,6 @@ const SceneToolbar: React.FC<SceneToolbarProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHomeClick = async () => {
|
|
||||||
if (!onSelectModel) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let zones: Zone[] = Array.isArray(currentZones) ? currentZones : [];
|
|
||||||
|
|
||||||
// Если зоны ещё не загружены, откройте Monitoring и загрузите зоны для текущего объекта
|
|
||||||
if ((!zones || zones.length === 0) && currentObject?.id) {
|
|
||||||
if (!showMonitoring) {
|
|
||||||
openMonitoring();
|
|
||||||
}
|
|
||||||
await loadZones(currentObject.id);
|
|
||||||
zones = useNavigationStore.getState().currentZones || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(zones) || zones.length === 0) {
|
|
||||||
console.warn('No zones available to select a model from.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = zones.slice().sort((a: Zone, b: Zone) => {
|
|
||||||
const oa = typeof a.order === 'number' ? a.order : 0;
|
|
||||||
const ob = typeof b.order === 'number' ? b.order : 0;
|
|
||||||
if (oa !== ob) return oa - ob;
|
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
|
||||||
});
|
|
||||||
|
|
||||||
const top = sorted[0];
|
|
||||||
let chosenPath: string | null = top?.model_path && String(top.model_path).trim() ? top.model_path! : null;
|
|
||||||
if (!chosenPath) {
|
|
||||||
const nextWithModel = sorted.find((z) => z.model_path && String(z.model_path).trim());
|
|
||||||
chosenPath = nextWithModel?.model_path ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chosenPath) {
|
|
||||||
onSelectModel(chosenPath);
|
|
||||||
} else {
|
|
||||||
console.warn('No zone has a valid model_path to open.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error selecting top zone model:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultButtons: ToolbarButton[] = [
|
const defaultButtons: ToolbarButton[] = [
|
||||||
{
|
{
|
||||||
icon: '/icons/Zoom.png',
|
icon: '/icons/Zoom.png',
|
||||||
@@ -127,14 +83,9 @@ const SceneToolbar: React.FC<SceneToolbarProps> = ({
|
|||||||
onClick: onToggleSensorHighlights || (() => console.log('Toggle Sensor Highlights')),
|
onClick: onToggleSensorHighlights || (() => console.log('Toggle Sensor Highlights')),
|
||||||
active: sensorHighlightsActive,
|
active: sensorHighlightsActive,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: '/icons/Warehouse.png',
|
|
||||||
label: 'Домой',
|
|
||||||
onClick: handleHomeClick,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: '/icons/Layers.png',
|
icon: '/icons/Layers.png',
|
||||||
label: 'Уровни',
|
label: 'Общий вид',
|
||||||
onClick: handleToggleNavMenu,
|
onClick: handleToggleNavMenu,
|
||||||
active: navMenuActive,
|
active: navMenuActive,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ interface SceneToolbarProps {
|
|||||||
onSelectModel?: (modelPath: string) => void;
|
onSelectModel?: (modelPath: string) => void;
|
||||||
panActive?: boolean;
|
panActive?: boolean;
|
||||||
navMenuActive?: boolean;
|
navMenuActive?: boolean;
|
||||||
|
onToggleSensorHighlights?: () => void;
|
||||||
|
sensorHighlightsActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SceneToolbar: React.FC<SceneToolbarProps> = ({
|
const SceneToolbar: React.FC<SceneToolbarProps> = ({
|
||||||
@@ -31,6 +33,8 @@ const SceneToolbar: React.FC<SceneToolbarProps> = ({
|
|||||||
onSelectModel,
|
onSelectModel,
|
||||||
panActive = false,
|
panActive = false,
|
||||||
navMenuActive = false,
|
navMenuActive = false,
|
||||||
|
onToggleSensorHighlights,
|
||||||
|
sensorHighlightsActive = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [isZoomOpen, setIsZoomOpen] = useState(false);
|
const [isZoomOpen, setIsZoomOpen] = useState(false);
|
||||||
const { showMonitoring, openMonitoring, closeMonitoring, currentZones, loadZones, currentObject } = useNavigationStore();
|
const { showMonitoring, openMonitoring, closeMonitoring, currentZones, loadZones, currentObject } = useNavigationStore();
|
||||||
@@ -43,88 +47,45 @@ const SceneToolbar: React.FC<SceneToolbarProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHomeClick = async () => {
|
|
||||||
if (!onSelectModel) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let zones: Zone[] = Array.isArray(currentZones) ? currentZones : [];
|
|
||||||
|
|
||||||
// Если зоны ещё не загружены, откройте Monitoring и загрузите зоны для текущего объекта
|
|
||||||
if ((!zones || zones.length === 0) && currentObject?.id) {
|
|
||||||
if (!showMonitoring) {
|
|
||||||
openMonitoring();
|
|
||||||
}
|
|
||||||
await loadZones(currentObject.id);
|
|
||||||
zones = useNavigationStore.getState().currentZones || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(zones) || zones.length === 0) {
|
|
||||||
console.warn('No zones available to select a model from.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = zones.slice().sort((a: Zone, b: Zone) => {
|
|
||||||
const oa = typeof a.order === 'number' ? a.order : 0;
|
|
||||||
const ob = typeof b.order === 'number' ? b.order : 0;
|
|
||||||
if (oa !== ob) return oa - ob;
|
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
|
||||||
});
|
|
||||||
|
|
||||||
const top = sorted[0];
|
|
||||||
let chosenPath: string | null = top?.model_path && String(top.model_path).trim() ? top.model_path! : null;
|
|
||||||
if (!chosenPath) {
|
|
||||||
const nextWithModel = sorted.find((z) => z.model_path && String(z.model_path).trim());
|
|
||||||
chosenPath = nextWithModel?.model_path ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chosenPath) {
|
|
||||||
onSelectModel(chosenPath);
|
|
||||||
} else {
|
|
||||||
console.warn('No zone has a valid model_path to open.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error selecting top zone model:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultButtons: ToolbarButton[] = [
|
const defaultButtons: ToolbarButton[] = [
|
||||||
{
|
{
|
||||||
icon: '/icons/Zoom.png',
|
icon: '/icons/Zoom.png',
|
||||||
label: 'Zoom',
|
label: 'Масштаб',
|
||||||
onClick: () => setIsZoomOpen(!isZoomOpen),
|
onClick: () => setIsZoomOpen(!isZoomOpen),
|
||||||
active: isZoomOpen,
|
active: isZoomOpen,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
icon: '/icons/plus.svg',
|
icon: '/icons/plus.svg',
|
||||||
label: 'Zoom In',
|
label: 'Приблизить',
|
||||||
onClick: onZoomIn || (() => {}),
|
onClick: onZoomIn || (() => {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '/icons/minus.svg',
|
icon: '/icons/minus.svg',
|
||||||
label: 'Zoom Out',
|
label: 'Отдалить',
|
||||||
onClick: onZoomOut || (() => {}),
|
onClick: onZoomOut || (() => {}),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '/icons/Video.png',
|
icon: '/icons/Video.png',
|
||||||
label: "Top View",
|
label: 'Вид сверху',
|
||||||
onClick: onTopView || (() => console.log('Top View')),
|
onClick: onTopView || (() => console.log('Top View')),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '/icons/Pointer.png',
|
icon: '/icons/Pointer.png',
|
||||||
label: 'Pan',
|
label: 'Панорамирование',
|
||||||
onClick: onPan || (() => console.log('Pan')),
|
onClick: onPan || (() => console.log('Pan')),
|
||||||
active: panActive,
|
active: panActive,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '/icons/Warehouse.png',
|
icon: '/icons/Eye.png',
|
||||||
label: 'Home',
|
label: 'Подсветка датчиков',
|
||||||
onClick: handleHomeClick,
|
onClick: onToggleSensorHighlights || (() => console.log('Toggle Sensor Highlights')),
|
||||||
|
active: sensorHighlightsActive,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '/icons/Layers.png',
|
icon: '/icons/Layers.png',
|
||||||
label: 'Levels',
|
label: 'Общий вид',
|
||||||
onClick: handleToggleNavMenu,
|
onClick: handleToggleNavMenu,
|
||||||
active: navMenuActive,
|
active: navMenuActive,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import useNavigationStore from '@/app/store/navigationStore'
|
import useNavigationStore from '@/app/store/navigationStore'
|
||||||
|
import AreaChart from '../dashboard/AreaChart'
|
||||||
|
|
||||||
interface DetectorType {
|
interface DetectorType {
|
||||||
detector_id: number
|
detector_id: number
|
||||||
@@ -53,9 +54,81 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
const formattedTimestamp = latestTimestamp
|
const formattedTimestamp = latestTimestamp
|
||||||
? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
: 'Нет данных'
|
: 'Нет данных'
|
||||||
|
|
||||||
|
// Данные для графика за последний месяц из реальных notifications
|
||||||
|
const chartData = React.useMemo(() => {
|
||||||
|
const notifications = detector.notifications ?? []
|
||||||
|
const DAYS_COUNT = 30 // Последний месяц
|
||||||
|
|
||||||
|
if (notifications.length === 0) {
|
||||||
|
// Если нет уведомлений, возвращаем пустой график
|
||||||
|
return Array.from({ length: DAYS_COUNT }, (_, i) => ({
|
||||||
|
timestamp: new Date(Date.now() - (DAYS_COUNT - 1 - i) * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
critical: 0,
|
||||||
|
warning: 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем уведомления по дням за последний месяц
|
||||||
|
const now = new Date()
|
||||||
|
// Устанавливаем время на начало текущего дня для корректного подсчёта
|
||||||
|
now.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
// Начальная дата: DAYS_COUNT-1 дней назад (чтобы включить текущий день)
|
||||||
|
const startDate = new Date(now.getTime() - (DAYS_COUNT - 1) * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
// Создаём карту: дата -> { critical: count, warning: count }
|
||||||
|
const dayMap: Record<string, { critical: number; warning: number }> = {}
|
||||||
|
|
||||||
|
// Инициализируем все дни нулями (включая текущий день)
|
||||||
|
for (let i = 0; i < DAYS_COUNT; i++) {
|
||||||
|
const date = new Date(startDate.getTime() + i * 24 * 60 * 60 * 1000)
|
||||||
|
const dateKey = date.toISOString().split('T')[0]
|
||||||
|
dayMap[dateKey] = { critical: 0, warning: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчитываем уведомления по дням
|
||||||
|
notifications.forEach(notification => {
|
||||||
|
// Детальное логирование для GLE-1
|
||||||
|
if (detector.serial_number === 'GLE-1') {
|
||||||
|
console.log('[DetectorMenu] Full notification object for GLE-1:', JSON.stringify(notification, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifDate = new Date(notification.timestamp)
|
||||||
|
// Проверяем что уведомление попадает в диапазон от startDate до конца текущего дня
|
||||||
|
const endOfToday = new Date(now.getTime() + 24 * 60 * 60 * 1000)
|
||||||
|
if (notifDate >= startDate && notifDate < endOfToday) {
|
||||||
|
const dateKey = notifDate.toISOString().split('T')[0]
|
||||||
|
if (dayMap[dateKey]) {
|
||||||
|
const notifType = String(notification.type || '').toLowerCase()
|
||||||
|
|
||||||
|
if (notifType === 'critical') {
|
||||||
|
dayMap[dateKey].critical++
|
||||||
|
} else if (notifType === 'warning') {
|
||||||
|
dayMap[dateKey].warning++
|
||||||
|
} else {
|
||||||
|
// Если тип не распознан, логируем
|
||||||
|
console.warn('[DetectorMenu] Unknown notification type:', notification.type, 'Full notification:', JSON.stringify(notification, null, 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Преобразуем в массив для графика
|
||||||
|
return Object.keys(dayMap)
|
||||||
|
.sort()
|
||||||
|
.map(dateKey => ({
|
||||||
|
timestamp: dateKey,
|
||||||
|
label: new Date(dateKey).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }),
|
||||||
|
critical: dayMap[dateKey].critical,
|
||||||
|
warning: dayMap[dateKey].warning,
|
||||||
|
}))
|
||||||
|
}, [detector.notifications])
|
||||||
|
|
||||||
// Определение типа детектора и его отображаемого названия
|
// Определение типа детектора и его отображаемого названия
|
||||||
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 ''
|
||||||
@@ -64,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> = {
|
||||||
@@ -72,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] || '—'
|
||||||
|
|
||||||
// Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором
|
// Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором
|
||||||
@@ -216,8 +306,16 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
// Компактный режим с якорной позицией (всплывающее окно)
|
// Компактный режим с якорной позицией (всплывающее окно)
|
||||||
// Используется для отображения информации при наведении на детектор в списке
|
// Используется для отображения информации при наведении на детектор в списке
|
||||||
if (compact && anchor) {
|
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 (
|
return (
|
||||||
<div className="absolute z-40" style={{ left: anchor.left, top: anchor.top }}>
|
<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="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 items-start justify-between gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -244,6 +342,14 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<DetailsSection compact={true} />
|
<DetailsSection compact={true} />
|
||||||
|
|
||||||
|
{/* График за последний месяц */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px] mb-2">График за месяц</div>
|
||||||
|
<div className="min-h-[100px]">
|
||||||
|
<AreaChart data={chartData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -279,6 +385,14 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
{/* Секция с детальной информацией о детекторе */}
|
{/* Секция с детальной информацией о детекторе */}
|
||||||
<DetailsSection />
|
<DetailsSection />
|
||||||
|
|
||||||
|
{/* График за последний месяц */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<h4 className="text-white text-base font-medium mb-4">График за последний месяц</h4>
|
||||||
|
<div className="min-h-[200px] bg-[rgb(22,24,36)] rounded-lg p-4">
|
||||||
|
<AreaChart data={chartData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Кнопка закрытия панели */}
|
{/* Кнопка закрытия панели */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|||||||
381
frontend/components/navigation/DetectorMenu.tsx — копия 5
Normal file
381
frontend/components/navigation/DetectorMenu.tsx — копия 5
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
'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' })
|
||||||
|
: 'Нет данных'
|
||||||
|
|
||||||
|
// Данные для графика за последний месяц из реальных notifications
|
||||||
|
const chartData = React.useMemo(() => {
|
||||||
|
const notifications = detector.notifications ?? []
|
||||||
|
const DAYS_COUNT = 30 // Последний месяц
|
||||||
|
|
||||||
|
if (notifications.length === 0) {
|
||||||
|
// Если нет уведомлений, возвращаем пустой график
|
||||||
|
return Array.from({ length: DAYS_COUNT }, (_, i) => ({
|
||||||
|
timestamp: new Date(Date.now() - (DAYS_COUNT - 1 - i) * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
critical: 0,
|
||||||
|
warning: 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем уведомления по дням за последний месяц
|
||||||
|
const now = new Date()
|
||||||
|
const monthAgo = new Date(now.getTime() - DAYS_COUNT * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
// Создаём карту: дата -> { critical: count, warning: count }
|
||||||
|
const dayMap: Record<string, { critical: number; warning: number }> = {}
|
||||||
|
|
||||||
|
// Инициализируем все дни нулями
|
||||||
|
for (let i = 0; i < DAYS_COUNT; i++) {
|
||||||
|
const date = new Date(monthAgo.getTime() + i * 24 * 60 * 60 * 1000)
|
||||||
|
const dateKey = date.toISOString().split('T')[0]
|
||||||
|
dayMap[dateKey] = { critical: 0, warning: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчитываем уведомления по дням
|
||||||
|
notifications.forEach(notification => {
|
||||||
|
const notifDate = new Date(notification.timestamp)
|
||||||
|
if (notifDate >= monthAgo && notifDate <= now) {
|
||||||
|
const dateKey = notifDate.toISOString().split('T')[0]
|
||||||
|
if (dayMap[dateKey]) {
|
||||||
|
const notifType = String(notification.type || '').toLowerCase()
|
||||||
|
console.log('[DetectorMenu] Notification type:', notifType, 'for sensor:', detector.serial_number)
|
||||||
|
|
||||||
|
if (notifType === 'critical') {
|
||||||
|
dayMap[dateKey].critical++
|
||||||
|
} else if (notifType === 'warning') {
|
||||||
|
dayMap[dateKey].warning++
|
||||||
|
} else {
|
||||||
|
// Если тип не распознан, логируем
|
||||||
|
console.warn('[DetectorMenu] Unknown notification type:', notification.type, 'notification:', notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Преобразуем в массив для графика
|
||||||
|
return Object.keys(dayMap)
|
||||||
|
.sort()
|
||||||
|
.map(dateKey => ({
|
||||||
|
timestamp: dateKey,
|
||||||
|
label: new Date(dateKey).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }),
|
||||||
|
critical: dayMap[dateKey].critical,
|
||||||
|
warning: dayMap[dateKey].warning,
|
||||||
|
}))
|
||||||
|
}, [detector.notifications])
|
||||||
|
|
||||||
|
// Определение типа детектора и его отображаемого названия
|
||||||
|
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} />
|
||||||
|
|
||||||
|
{/* График за последний месяц */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px] mb-2">График за месяц</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 />
|
||||||
|
|
||||||
|
{/* График за последний месяц */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<h4 className="text-white text-base font-medium mb-4">График за последний месяц</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
|
||||||
@@ -35,13 +35,22 @@ interface MonitoringProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
||||||
const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore();
|
const { currentObject, currentZones, zonesLoading, zonesError, loadZones, currentModelPath } = useNavigationStore();
|
||||||
|
const [autoSelectedRef, setAutoSelectedRef] = React.useState(false);
|
||||||
|
|
||||||
const handleSelectModel = useCallback((modelPath: string) => {
|
const handleSelectModel = useCallback((modelPath: string) => {
|
||||||
console.log(`[Monitoring] Model selected: ${modelPath}`);
|
console.log(`[Monitoring] Model selected: ${modelPath}`);
|
||||||
console.log(`[Monitoring] onSelectModel callback:`, onSelectModel);
|
console.log(`[Monitoring] onSelectModel callback:`, onSelectModel);
|
||||||
onSelectModel?.(modelPath);
|
onSelectModel?.(modelPath);
|
||||||
}, [onSelectModel]);
|
|
||||||
|
// Автоматически закрываем панель после выбора модели
|
||||||
|
if (onClose) {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('[Monitoring] Auto-closing after model selection');
|
||||||
|
onClose();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [onSelectModel, onClose]);
|
||||||
|
|
||||||
// Загрузка зон при изменении объекта
|
// Загрузка зон при изменении объекта
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,8 +60,12 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
loadZones(objId);
|
loadZones(objId);
|
||||||
}, [currentObject?.id, loadZones]);
|
}, [currentObject?.id, loadZones]);
|
||||||
|
|
||||||
// Автоматический выбор первой зоны ОТКЛЮЧЕН - пользователь должен выбрать модель вручную
|
// Сброс флага при изменении объекта
|
||||||
|
useEffect(() => {
|
||||||
|
setAutoSelectedRef(false);
|
||||||
|
}, [currentObject?.id])
|
||||||
|
|
||||||
|
// Сортировка зон по order
|
||||||
const sortedZones: Zone[] = React.useMemo(() => {
|
const sortedZones: Zone[] = React.useMemo(() => {
|
||||||
const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
||||||
const oa = typeof a.order === 'number' ? a.order : 0;
|
const oa = typeof a.order === 'number' ? a.order : 0;
|
||||||
@@ -64,6 +77,19 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
return sorted;
|
return sorted;
|
||||||
}, [currentZones]);
|
}, [currentZones]);
|
||||||
|
|
||||||
|
// Автоматический выбор модели с order=0 при загрузке зон
|
||||||
|
useEffect(() => {
|
||||||
|
// Если уже был автовыбор - пропускаем
|
||||||
|
if (autoSelectedRef || !onSelectModel) return;
|
||||||
|
|
||||||
|
// Если есть зоны и первая зона (order=0) имеет model_path
|
||||||
|
if (sortedZones.length > 0 && sortedZones[0]?.model_path) {
|
||||||
|
console.log('[Monitoring] Auto-selecting model with order=0:', sortedZones[0].model_path);
|
||||||
|
setAutoSelectedRef(true);
|
||||||
|
onSelectModel(sortedZones[0].model_path);
|
||||||
|
}
|
||||||
|
}, [sortedZones, autoSelectedRef, onSelectModel])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
|
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
|
||||||
|
|||||||
@@ -35,17 +35,22 @@ interface MonitoringProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
||||||
const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore();
|
const { currentObject, currentZones, zonesLoading, zonesError, loadZones, currentModelPath } = useNavigationStore();
|
||||||
const [userSelectedModel, setUserSelectedModel] = useState(false);
|
const [autoSelectedRef, setAutoSelectedRef] = React.useState(false);
|
||||||
|
|
||||||
const handleSelectModel = useCallback((modelPath: string, isUserClick = false) => {
|
const handleSelectModel = useCallback((modelPath: string) => {
|
||||||
console.log(`[Monitoring] Model selected: ${modelPath}, isUserClick: ${isUserClick}`);
|
console.log(`[Monitoring] Model selected: ${modelPath}`);
|
||||||
console.log(`[Monitoring] onSelectModel callback:`, onSelectModel);
|
console.log(`[Monitoring] onSelectModel callback:`, onSelectModel);
|
||||||
if (isUserClick) {
|
|
||||||
setUserSelectedModel(true);
|
|
||||||
}
|
|
||||||
onSelectModel?.(modelPath);
|
onSelectModel?.(modelPath);
|
||||||
}, [onSelectModel]);
|
|
||||||
|
// Автоматически закрываем панель после выбора модели
|
||||||
|
if (onClose) {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('[Monitoring] Auto-closing after model selection');
|
||||||
|
onClose();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [onSelectModel, onClose]);
|
||||||
|
|
||||||
// Загрузка зон при изменении объекта
|
// Загрузка зон при изменении объекта
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,25 +60,19 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
loadZones(objId);
|
loadZones(objId);
|
||||||
}, [currentObject?.id, loadZones]);
|
}, [currentObject?.id, loadZones]);
|
||||||
|
|
||||||
// Автоматический выбор первой зоны при загрузке (только если пользователь не выбрал вручную)
|
// Автоматический выбор модели, если currentModelPath установлен (переход из таблицы)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSelectedModel) {
|
if (!currentModelPath || autoSelectedRef || !onSelectModel) return;
|
||||||
console.log('[Monitoring] User already selected model, skipping auto-selection');
|
|
||||||
return;
|
console.log('[Monitoring] Auto-selecting model from currentModelPath:', currentModelPath);
|
||||||
}
|
setAutoSelectedRef(true);
|
||||||
|
onSelectModel(currentModelPath);
|
||||||
const sortedZones: Zone[] = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
}, [currentModelPath, autoSelectedRef, onSelectModel]);
|
||||||
const oa = typeof a.order === 'number' ? a.order : 0;
|
|
||||||
const ob = typeof b.order === 'number' ? b.order : 0;
|
// Сброс флага при изменении объекта
|
||||||
if (oa !== ob) return oa - ob;
|
useEffect(() => {
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
setAutoSelectedRef(false);
|
||||||
});
|
}, [currentObject?.id])
|
||||||
|
|
||||||
if (sortedZones.length > 0 && sortedZones[0].model_path && !zonesLoading) {
|
|
||||||
console.log('[Monitoring] Auto-selecting first zone model');
|
|
||||||
handleSelectModel(sortedZones[0].model_path, false);
|
|
||||||
}
|
|
||||||
}, [currentZones, zonesLoading, handleSelectModel, userSelectedModel]);
|
|
||||||
|
|
||||||
const sortedZones: Zone[] = React.useMemo(() => {
|
const sortedZones: Zone[] = React.useMemo(() => {
|
||||||
const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
||||||
@@ -119,26 +118,30 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
<button
|
<button
|
||||||
key={`zone-${sortedZones[0].id}-panorama`}
|
key={`zone-${sortedZones[0].id}-panorama`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => sortedZones[0].model_path ? handleSelectModel(sortedZones[0].model_path, true) : null}
|
onClick={() => sortedZones[0].model_path ? handleSelectModel(sortedZones[0].model_path) : null}
|
||||||
className="w-full bg-gray-300 rounded-lg h-[200px] flex items-center justify-center hover:bg-gray-400 transition-colors mb-4"
|
className="group w-full bg-gradient-to-br from-cyan-600/20 via-blue-600/20 to-purple-600/20 rounded-xl h-[220px] flex items-center justify-center hover:from-cyan-500/30 hover:via-blue-500/30 hover:to-purple-500/30 transition-all duration-300 mb-4 border border-cyan-400/30 hover:border-cyan-400/60 shadow-lg shadow-cyan-500/10 hover:shadow-cyan-500/30 overflow-hidden relative backdrop-blur-sm"
|
||||||
title={sortedZones[0].model_path ? `Открыть 3D модель зоны: ${sortedZones[0].name}` : 'Модель зоны отсутствует'}
|
title={sortedZones[0].model_path ? `Открыть 3D модель зоны: ${sortedZones[0].name}` : 'Модель зоны отсутствует'}
|
||||||
disabled={!sortedZones[0].model_path}
|
disabled={!sortedZones[0].model_path}
|
||||||
>
|
>
|
||||||
<div className="w-full h-full bg-gray-200 rounded flex flex-col items-center justify-center relative">
|
{/* Градиентный фон при наведении */}
|
||||||
{/* Всегда рендерим с разрешённой заглушкой */}
|
<div className="absolute inset-0 bg-gradient-to-br from-cyan-400/10 via-blue-400/10 to-purple-400/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||||
|
{/* Анимированный градиент по краям */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-cyan-400/5 to-transparent animate-pulse"></div>
|
||||||
|
|
||||||
|
<div className="w-full h-full rounded-lg flex flex-col items-center justify-center relative">
|
||||||
<Image
|
<Image
|
||||||
src={resolveImageSrc(sortedZones[0].image_path)}
|
src={resolveImageSrc(sortedZones[0].image_path)}
|
||||||
alt={sortedZones[0].name || 'Зона'}
|
alt={sortedZones[0].name || 'Зона'}
|
||||||
width={200}
|
width={200}
|
||||||
height={200}
|
height={200}
|
||||||
className="max-w-full max-h-full object-contain opacity-50"
|
className="max-w-full max-h-full object-contain opacity-60 group-hover:opacity-80 transition-opacity duration-300"
|
||||||
style={{ height: 'auto' }}
|
style={{ height: 'auto' }}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
target.src = '/images/test_image.png';
|
target.src = '/images/test_image.png';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-2 left-2 right-2 text-sm text-gray-700 bg-white/80 rounded px-3 py-1 truncate">
|
<div className="absolute bottom-3 left-3 right-3 text-sm font-medium text-white bg-gradient-to-r from-cyan-600/80 via-blue-600/80 to-purple-600/80 backdrop-blur-md rounded-lg px-4 py-2 truncate border border-cyan-400/40 shadow-lg shadow-cyan-500/20">
|
||||||
{sortedZones[0].name}
|
{sortedZones[0].name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,25 +153,30 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
<button
|
<button
|
||||||
key={`zone-${zone.id}-${idx}`}
|
key={`zone-${zone.id}-${idx}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => zone.model_path ? handleSelectModel(zone.model_path, true) : null}
|
onClick={() => zone.model_path ? handleSelectModel(zone.model_path) : null}
|
||||||
className="relative flex-1 bg-gray-300 rounded-lg h-[120px] flex items-center justify-center hover:bg-gray-400 transition-colors"
|
className="group relative flex-1 bg-gradient-to-br from-emerald-600/20 via-teal-600/20 to-cyan-600/20 rounded-xl h-[140px] flex items-center justify-center hover:from-emerald-500/30 hover:via-teal-500/30 hover:to-cyan-500/30 transition-all duration-300 border border-emerald-400/30 hover:border-emerald-400/60 shadow-lg shadow-emerald-500/10 hover:shadow-emerald-500/30 overflow-hidden backdrop-blur-sm"
|
||||||
title={zone.model_path ? `Открыть 3D модель зоны: ${zone.name}` : 'Модель зоны отсутствует'}
|
title={zone.model_path ? `Открыть 3D модель зоны: ${zone.name}` : 'Модель зоны отсутствует'}
|
||||||
disabled={!zone.model_path}
|
disabled={!zone.model_path}
|
||||||
>
|
>
|
||||||
<div className="w-full h-full bg-gray-200 rounded flex flex-col items-center justify-center relative">
|
{/* Градиентный фон при наведении */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-emerald-400/10 via-teal-400/10 to-cyan-400/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||||
|
{/* Анимированный градиент по краям */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-emerald-400/5 to-transparent animate-pulse"></div>
|
||||||
|
|
||||||
|
<div className="w-full h-full rounded-lg flex flex-col items-center justify-center relative">
|
||||||
<Image
|
<Image
|
||||||
src={resolveImageSrc(zone.image_path)}
|
src={resolveImageSrc(zone.image_path)}
|
||||||
alt={zone.name || 'Зона'}
|
alt={zone.name || 'Зона'}
|
||||||
width={120}
|
width={120}
|
||||||
height={120}
|
height={120}
|
||||||
className="max-w-full max-h-full object-contain opacity-50"
|
className="max-w-full max-h-full object-contain opacity-60 group-hover:opacity-80 transition-opacity duration-300"
|
||||||
style={{ height: 'auto' }}
|
style={{ height: 'auto' }}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
target.src = '/images/test_image.png';
|
target.src = '/images/test_image.png';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-1 left-1 right-1 text-[10px] text-gray-700 bg-white/70 rounded px-2 py-0.5 truncate">
|
<div className="absolute bottom-2 left-2 right-2 text-[11px] font-medium text-white bg-gradient-to-r from-emerald-600/80 via-teal-600/80 to-cyan-600/80 backdrop-blur-md rounded-md px-2 py-1 truncate border border-emerald-400/40 shadow-md shadow-emerald-500/20">
|
||||||
{zone.name}
|
{zone.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
import React, { useEffect, useCallback, useState } from 'react';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import useNavigationStore from '@/app/store/navigationStore';
|
|
||||||
import type { Zone } from '@/app/types';
|
|
||||||
|
|
||||||
// Безопасный резолвер src изображения, чтобы избежать ошибок Invalid URL в next/image
|
|
||||||
const resolveImageSrc = (src?: string | null): string => {
|
|
||||||
if (!src || typeof src !== 'string') return '/images/test_image.png';
|
|
||||||
let s = src.trim();
|
|
||||||
if (!s) return '/images/test_image.png';
|
|
||||||
s = s.replace(/\\/g, '/');
|
|
||||||
const lower = s.toLowerCase();
|
|
||||||
// Явный плейсхолдер test_image.png маппим на наш статический ресурс
|
|
||||||
if (lower === 'test_image.png' || lower.endsWith('/test_image.png') || lower.includes('/public/images/test_image.png')) {
|
|
||||||
return '/images/test_image.png';
|
|
||||||
}
|
|
||||||
// Если путь содержит public/images (даже абсолютный путь ФС), переводим в относительный путь сайта
|
|
||||||
if (/\/public\/images\//i.test(s)) {
|
|
||||||
const parts = s.split(/\/public\/images\//i);
|
|
||||||
const rel = parts[1] || '';
|
|
||||||
return `/images/${rel}`;
|
|
||||||
}
|
|
||||||
// Абсолютные URL и пути, относительные к сайту
|
|
||||||
if (s.startsWith('http://') || s.startsWith('https://')) return s;
|
|
||||||
if (s.startsWith('/')) return s;
|
|
||||||
// Нормализуем относительные имена ресурсов до путей сайта под /images
|
|
||||||
// Убираем ведущий 'public/', если он присутствует
|
|
||||||
s = s.replace(/^public\//i, '');
|
|
||||||
return s.startsWith('images/') ? `/${s}` : `/images/${s}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MonitoringProps {
|
|
||||||
onClose?: () => void;
|
|
||||||
onSelectModel?: (modelPath: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|
||||||
const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore();
|
|
||||||
|
|
||||||
const handleSelectModel = useCallback((modelPath: string) => {
|
|
||||||
console.log(`[Monitoring] Model selected: ${modelPath}`);
|
|
||||||
console.log(`[Monitoring] onSelectModel callback:`, onSelectModel);
|
|
||||||
onSelectModel?.(modelPath);
|
|
||||||
}, [onSelectModel]);
|
|
||||||
|
|
||||||
// Загрузка зон при изменении объекта
|
|
||||||
useEffect(() => {
|
|
||||||
const objId = currentObject?.id;
|
|
||||||
if (!objId) return;
|
|
||||||
console.log(`[Monitoring] Loading zones for object ID: ${objId}`);
|
|
||||||
loadZones(objId);
|
|
||||||
}, [currentObject?.id, loadZones]);
|
|
||||||
|
|
||||||
// Автоматический выбор первой зоны ОТКЛЮЧЕН - пользователь должен выбрать модель вручную
|
|
||||||
|
|
||||||
const sortedZones: Zone[] = React.useMemo(() => {
|
|
||||||
const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
|
||||||
const oa = typeof a.order === 'number' ? a.order : 0;
|
|
||||||
const ob = typeof b.order === 'number' ? b.order : 0;
|
|
||||||
if (oa !== ob) return oa - ob;
|
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
|
||||||
});
|
|
||||||
console.log(`[Monitoring] Sorted zones:`, sorted.map(z => ({ id: z.id, name: z.name, model_path: z.model_path })));
|
|
||||||
return sorted;
|
|
||||||
}, [currentZones]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-white text-2xl font-semibold">Зоны мониторинга</h2>
|
|
||||||
{onClose && (
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-white hover:text-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" 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>
|
|
||||||
{/* UI зон */}
|
|
||||||
{zonesError && (
|
|
||||||
<div className="rounded-lg bg-red-600/20 border border-red-600/40 text-red-200 text-xs px-3 py-2">
|
|
||||||
Ошибка загрузки зон: {zonesError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{zonesLoading && (
|
|
||||||
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
|
|
||||||
Загрузка зон...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sortedZones.length > 0 && (
|
|
||||||
<>
|
|
||||||
{sortedZones[0] && (
|
|
||||||
<button
|
|
||||||
key={`zone-${sortedZones[0].id}-panorama`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => sortedZones[0].model_path ? handleSelectModel(sortedZones[0].model_path) : null}
|
|
||||||
className="w-full bg-gray-300 rounded-lg h-[200px] flex items-center justify-center hover:bg-gray-400 transition-colors mb-4"
|
|
||||||
title={sortedZones[0].model_path ? `Открыть 3D модель зоны: ${sortedZones[0].name}` : 'Модель зоны отсутствует'}
|
|
||||||
disabled={!sortedZones[0].model_path}
|
|
||||||
>
|
|
||||||
<div className="w-full h-full bg-gray-200 rounded flex flex-col items-center justify-center relative">
|
|
||||||
{/* Всегда рендерим с разрешённой заглушкой */}
|
|
||||||
<Image
|
|
||||||
src={resolveImageSrc(sortedZones[0].image_path)}
|
|
||||||
alt={sortedZones[0].name || 'Зона'}
|
|
||||||
width={200}
|
|
||||||
height={200}
|
|
||||||
className="max-w-full max-h-full object-contain opacity-50"
|
|
||||||
style={{ height: 'auto' }}
|
|
||||||
onError={(e) => {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
target.src = '/images/test_image.png';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-2 left-2 right-2 text-sm text-gray-700 bg-white/80 rounded px-3 py-1 truncate">
|
|
||||||
{sortedZones[0].name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{sortedZones.length > 1 && (
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{sortedZones.slice(1).map((zone: Zone, idx: number) => (
|
|
||||||
<button
|
|
||||||
key={`zone-${zone.id}-${idx}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => zone.model_path ? handleSelectModel(zone.model_path) : null}
|
|
||||||
className="relative flex-1 bg-gray-300 rounded-lg h-[120px] flex items-center justify-center hover:bg-gray-400 transition-colors"
|
|
||||||
title={zone.model_path ? `Открыть 3D модель зоны: ${zone.name}` : 'Модель зоны отсутствует'}
|
|
||||||
disabled={!zone.model_path}
|
|
||||||
>
|
|
||||||
<div className="w-full h-full bg-gray-200 rounded flex flex-col items-center justify-center relative">
|
|
||||||
<Image
|
|
||||||
src={resolveImageSrc(zone.image_path)}
|
|
||||||
alt={zone.name || 'Зона'}
|
|
||||||
width={120}
|
|
||||||
height={120}
|
|
||||||
className="max-w-full max-h-full object-contain opacity-50"
|
|
||||||
style={{ height: 'auto' }}
|
|
||||||
onError={(e) => {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
target.src = '/images/test_image.png';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-1 left-1 right-1 text-[10px] text-gray-700 bg-white/70 rounded px-2 py-0.5 truncate">
|
|
||||||
{zone.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{sortedZones.length === 0 && !zonesError && !zonesLoading && (
|
|
||||||
<div className="col-span-2">
|
|
||||||
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
|
|
||||||
Зоны не найдены для выбранного объекта. Проверьте параметр objectId в API /api/get-zones.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Monitoring;
|
|
||||||
@@ -21,12 +21,7 @@ interface ObjectCardProps {
|
|||||||
isSelected?: boolean
|
isSelected?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Иконка редактирования
|
|
||||||
const EditIcon = ({ className }: { className?: string }) => (
|
|
||||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected = false }) => {
|
const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected = false }) => {
|
||||||
const navigationService = useNavigationService()
|
const navigationService = useNavigationService()
|
||||||
@@ -39,11 +34,7 @@ const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected =
|
|||||||
navigationService.selectObjectAndGoToDashboard(object.object_id, object.title)
|
navigationService.selectObjectAndGoToDashboard(object.object_id, object.title)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditClick = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
console.log('Edit object:', object.object_id)
|
|
||||||
// Логика редактирования объекта
|
|
||||||
}
|
|
||||||
|
|
||||||
// Возврат к тестовому изображению, если src отсутствует/некорректен; нормализация относительных путей
|
// Возврат к тестовому изображению, если src отсутствует/некорректен; нормализация относительных путей
|
||||||
const resolveImageSrc = (src?: string | null): string => {
|
const resolveImageSrc = (src?: string | null): string => {
|
||||||
@@ -82,7 +73,7 @@ const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`flex flex-col w-full min-h-[414px] h-[414px] sm:h-auto sm:min-h-[350px] items-start gap-4 p-4 sm:p-6 relative bg-[#161824] rounded-[20px] overflow-hidden cursor-pointer transition-all duration-200 hover:bg-[#1a1d2e] ${
|
className={`flex flex-col w-full min-h-[340px] h-[340px] sm:h-auto sm:min-h-[300px] items-start gap-3 p-3 sm:p-4 relative bg-[#161824] rounded-[20px] overflow-hidden cursor-pointer transition-all duration-200 hover:bg-[#1a1d2e] border border-white/20 ${
|
||||||
isSelected ? 'ring-2 ring-blue-500' : ''
|
isSelected ? 'ring-2 ring-blue-500' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
@@ -103,20 +94,11 @@ const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected =
|
|||||||
{object.description}
|
{object.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className="inline-flex flex-shrink-0 bg-[#3193f5] h-10 items-center justify-center gap-2 px-3 sm:px-4 py-2 relative rounded-md transition-colors duration-200 hover:bg-[#2563eb] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 w-full sm:w-auto"
|
|
||||||
aria-label={`Изменить ${object.title}`}
|
|
||||||
onClick={handleEditClick}
|
|
||||||
>
|
|
||||||
<EditIcon className="!relative !w-4 !h-4 text-white flex-shrink-0" />
|
|
||||||
<span className="font-medium text-white text-sm leading-5 relative tracking-[0] sm:whitespace-nowrap">
|
|
||||||
Изменить
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Изображение объекта */}
|
{/* Изображение объекта */}
|
||||||
<div className="relative flex-1 self-stretch w-full grow bg-[#f1f1f1] rounded-lg overflow-hidden min-h-[200px] sm:min-h-[250px]">
|
<div className="relative flex-1 self-stretch w-full grow bg-[#f1f1f1] rounded-lg overflow-hidden min-h-[170px] sm:min-h-[200px]">
|
||||||
<Image
|
<Image
|
||||||
className="absolute w-full h-full top-0 left-0 object-cover"
|
className="absolute w-full h-full top-0 left-0 object-cover"
|
||||||
alt={object.title}
|
alt={object.title}
|
||||||
|
|||||||
138
frontend/components/objects/ObjectCard.tsx — копия
Normal file
138
frontend/components/objects/ObjectCard.tsx — копия
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { useNavigationService } from '@/services/navigationService'
|
||||||
|
interface ObjectData {
|
||||||
|
object_id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
image: string | null
|
||||||
|
location: string
|
||||||
|
floors?: number
|
||||||
|
area?: string
|
||||||
|
type?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ObjectCardProps {
|
||||||
|
object: ObjectData
|
||||||
|
onSelect?: (objectId: string) => void
|
||||||
|
isSelected?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Иконка редактирования
|
||||||
|
const EditIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected = false }) => {
|
||||||
|
const navigationService = useNavigationService()
|
||||||
|
|
||||||
|
const handleCardClick = () => {
|
||||||
|
if (onSelect) {
|
||||||
|
onSelect(object.object_id)
|
||||||
|
}
|
||||||
|
// Навигация к дашборду с выбранным объектом
|
||||||
|
navigationService.selectObjectAndGoToDashboard(object.object_id, object.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
console.log('Edit object:', object.object_id)
|
||||||
|
// Логика редактирования объекта
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возврат к тестовому изображению, если src отсутствует/некорректен; нормализация относительных путей
|
||||||
|
const resolveImageSrc = (src?: string | null): string => {
|
||||||
|
if (!src || typeof src !== 'string') return '/images/test_image.png'
|
||||||
|
let s = src.trim()
|
||||||
|
if (!s) return '/images/test_image.png'
|
||||||
|
|
||||||
|
// Нормализуем обратные слеши в стиле Windows
|
||||||
|
s = s.replace(/\\/g, '/')
|
||||||
|
const lower = s.toLowerCase()
|
||||||
|
|
||||||
|
// Обрабатываем явный плейсхолдер test_image.png только как заглушку
|
||||||
|
if (lower === 'test_image.png' || lower.endsWith('/test_image.png') || lower.includes('/public/images/test_image.png')) {
|
||||||
|
return '/images/test_image.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Абсолютные URL
|
||||||
|
if (s.startsWith('http://') || s.startsWith('https://')) return s
|
||||||
|
|
||||||
|
// Пути, относительные к сайту
|
||||||
|
if (s.startsWith('/')) {
|
||||||
|
// Преобразуем /public/images/... в /images/...
|
||||||
|
if (/\/public\/images\//i.test(s)) {
|
||||||
|
return s.replace(/\/public\/images\//i, '/images/')
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нормализуем относительные имена ресурсов до путей сайта под /images
|
||||||
|
// Убираем ведущий 'public/', если он присутствует
|
||||||
|
s = s.replace(/^public\//i, '')
|
||||||
|
return s.startsWith('images/') ? `/${s}` : `/images/${s}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgSrc = resolveImageSrc(object.image)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`flex flex-col w-full min-h-[414px] h-[414px] sm:h-auto sm:min-h-[350px] items-start gap-4 p-4 sm:p-6 relative bg-[#161824] rounded-[20px] overflow-hidden cursor-pointer transition-all duration-200 hover:bg-[#1a1d2e] ${
|
||||||
|
isSelected ? 'ring-2 ring-blue-500' : ''
|
||||||
|
}`}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
handleCardClick()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-2 relative self-stretch w-full flex-[0_0_auto]">
|
||||||
|
<div className="flex-col items-start flex-1 grow flex relative min-w-0">
|
||||||
|
<h2 className="self-stretch mt-[-1.00px] font-medium text-white text-lg leading-7 relative tracking-[0] break-words">
|
||||||
|
{object.title}
|
||||||
|
</h2>
|
||||||
|
<p className="self-stretch font-normal text-[#71717a] text-sm leading-5 relative tracking-[0] break-words">
|
||||||
|
{object.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="inline-flex flex-shrink-0 bg-[#3193f5] h-10 items-center justify-center gap-2 px-3 sm:px-4 py-2 relative rounded-md transition-colors duration-200 hover:bg-[#2563eb] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 w-full sm:w-auto"
|
||||||
|
aria-label={`Изменить ${object.title}`}
|
||||||
|
onClick={handleEditClick}
|
||||||
|
>
|
||||||
|
<EditIcon className="!relative !w-4 !h-4 text-white flex-shrink-0" />
|
||||||
|
<span className="font-medium text-white text-sm leading-5 relative tracking-[0] sm:whitespace-nowrap">
|
||||||
|
Изменить
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Изображение объекта */}
|
||||||
|
<div className="relative flex-1 self-stretch w-full grow bg-[#f1f1f1] rounded-lg overflow-hidden min-h-[200px] sm:min-h-[250px]">
|
||||||
|
<Image
|
||||||
|
className="absolute w-full h-full top-0 left-0 object-cover"
|
||||||
|
alt={object.title}
|
||||||
|
src={imgSrc}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
|
onError={(e) => {
|
||||||
|
// Заглушка при ошибке загрузки изображения
|
||||||
|
const target = e.target as HTMLImageElement
|
||||||
|
target.src = '/images/test_image.png'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ObjectCard
|
||||||
|
export type { ObjectData, ObjectCardProps }
|
||||||
@@ -10,12 +10,6 @@ interface ObjectGalleryProps {
|
|||||||
selectedObjectId?: string | null
|
selectedObjectId?: string | null
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const BackIcon = ({ className }: { className?: string }) => (
|
|
||||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
const ObjectGallery: React.FC<ObjectGalleryProps> = ({
|
const ObjectGallery: React.FC<ObjectGalleryProps> = ({
|
||||||
objects,
|
objects,
|
||||||
@@ -31,72 +25,36 @@ const ObjectGallery: React.FC<ObjectGalleryProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBackClick = () => {
|
|
||||||
console.log('Back clicked')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col items-start relative bg-[#0e111a] min-h-screen ${className}`}>
|
<div className={`w-full ${className}`}>
|
||||||
<main className="relative self-stretch w-full">
|
{/* Галерея объектов */}
|
||||||
<div className="flex flex-col w-full items-start gap-6 p-4 sm:p-8 lg:p-16">
|
{objects.length > 0 ? (
|
||||||
<header className="flex flex-col items-start gap-9 relative self-stretch w-full flex-[0_0_auto]">
|
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[18px] w-full">
|
||||||
<nav className="items-center gap-4 self-stretch w-full flex-[0_0_auto] flex relative">
|
{objects.map((object) => (
|
||||||
<button
|
<ObjectCard
|
||||||
className="flex w-10 bg-[#161824] h-10 items-center justify-center gap-2 px-2 py-2 relative rounded-md transition-colors duration-200 hover:bg-[#1a1d2e] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
key={object.object_id}
|
||||||
aria-label="Назад"
|
object={object}
|
||||||
onClick={handleBackClick}
|
onSelect={handleObjectSelect}
|
||||||
>
|
isSelected={selectedObjectId === object.object_id}
|
||||||
<BackIcon className="relative w-5 h-5 text-white" />
|
/>
|
||||||
</button>
|
))}
|
||||||
|
</section>
|
||||||
<div className="inline-flex flex-wrap items-center gap-2.5 relative flex-[0_0_auto]">
|
) : (
|
||||||
<div className="inline-flex items-center justify-center gap-2.5 relative flex-[0_0_auto]">
|
<div className="flex items-center justify-center h-64 w-full">
|
||||||
<span className="relative w-fit mt-[-1.00px] font-normal text-white text-sm tracking-[0] leading-5 whitespace-nowrap">
|
<div className="text-center">
|
||||||
{title}
|
<div className="text-[#71717a] mb-2">
|
||||||
</span>
|
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 24 24">
|
||||||
</div>
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||||
</div>
|
</svg>
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-4 relative self-stretch w-full flex-[0_0_auto]">
|
|
||||||
<h1 className="relative w-fit mt-[-1.00px] font-semibold text-white text-2xl tracking-[0] leading-8 whitespace-nowrap">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">Объекты не найдены</h3>
|
||||||
|
<p className="text-[#71717a]">Нет доступных объектов</p>
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
{/* Галерея объектов */}
|
|
||||||
{objects.length > 0 ? (
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[18px] w-full">
|
|
||||||
{objects.map((object) => (
|
|
||||||
<ObjectCard
|
|
||||||
key={object.object_id}
|
|
||||||
object={object}
|
|
||||||
onSelect={handleObjectSelect}
|
|
||||||
isSelected={selectedObjectId === object.object_id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-64 w-full">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-[#71717a] mb-2">
|
|
||||||
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium text-white mb-2">Объекты не найдены</h3>
|
|
||||||
<p className="text-[#71717a]">Нет доступных объектов</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ObjectGallery
|
export default ObjectGallery
|
||||||
export type { ObjectGalleryProps }
|
export type { ObjectGalleryProps }
|
||||||
|
|||||||
102
frontend/components/objects/ObjectGallery.tsx — копия
Normal file
102
frontend/components/objects/ObjectGallery.tsx — копия
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import ObjectCard, { ObjectData } from './ObjectCard'
|
||||||
|
|
||||||
|
interface ObjectGalleryProps {
|
||||||
|
objects: ObjectData[]
|
||||||
|
title?: string
|
||||||
|
onObjectSelect?: (objectId: string) => void
|
||||||
|
selectedObjectId?: string | null
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BackIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ObjectGallery: React.FC<ObjectGalleryProps> = ({
|
||||||
|
objects,
|
||||||
|
title = 'Объекты',
|
||||||
|
onObjectSelect,
|
||||||
|
selectedObjectId,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const handleObjectSelect = (objectId: string) => {
|
||||||
|
if (onObjectSelect) {
|
||||||
|
onObjectSelect(objectId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBackClick = () => {
|
||||||
|
console.log('Back clicked')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col items-start relative bg-[#0e111a] min-h-screen ${className}`}>
|
||||||
|
<main className="relative self-stretch w-full">
|
||||||
|
<div className="flex flex-col w-full items-start gap-6 p-4 sm:p-8 lg:p-16">
|
||||||
|
<header className="flex flex-col items-start gap-9 relative self-stretch w-full flex-[0_0_auto]">
|
||||||
|
<nav className="items-center gap-4 self-stretch w-full flex-[0_0_auto] flex relative">
|
||||||
|
<button
|
||||||
|
className="flex w-10 bg-[#161824] h-10 items-center justify-center gap-2 px-2 py-2 relative rounded-md transition-colors duration-200 hover:bg-[#1a1d2e] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
||||||
|
aria-label="Назад"
|
||||||
|
onClick={handleBackClick}
|
||||||
|
>
|
||||||
|
<BackIcon className="relative w-5 h-5 text-white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="inline-flex flex-wrap items-center gap-2.5 relative flex-[0_0_auto]">
|
||||||
|
<div className="inline-flex items-center justify-center gap-2.5 relative flex-[0_0_auto]">
|
||||||
|
<span className="relative w-fit mt-[-1.00px] font-normal text-white text-sm tracking-[0] leading-5 whitespace-nowrap">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4 relative self-stretch w-full flex-[0_0_auto]">
|
||||||
|
<h1 className="relative w-fit mt-[-1.00px] font-semibold text-white text-2xl tracking-[0] leading-8 whitespace-nowrap">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Галерея объектов */}
|
||||||
|
{objects.length > 0 ? (
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[18px] w-full">
|
||||||
|
{objects.map((object) => (
|
||||||
|
<ObjectCard
|
||||||
|
key={object.object_id}
|
||||||
|
object={object}
|
||||||
|
onSelect={handleObjectSelect}
|
||||||
|
isSelected={selectedObjectId === object.object_id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 w-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-[#71717a] mb-2">
|
||||||
|
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">Объекты не найдены</h3>
|
||||||
|
<p className="text-[#71717a]">Нет доступных объектов</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ObjectGallery
|
||||||
|
export type { ObjectGalleryProps }
|
||||||
@@ -74,6 +74,10 @@ const Monitor = ({ className }: { className?: string }) => (
|
|||||||
const Building = ({ className }: { className?: string }) => (
|
const Building = ({ className }: { className?: string }) => (
|
||||||
<IconWrapper src="/icons/BookOpen.png" alt="Building" className={className} />
|
<IconWrapper src="/icons/BookOpen.png" alt="Building" className={className} />
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const Building3D = ({ className }: { className?: string }) => (
|
||||||
|
<IconWrapper src="/icons/Building3D.png" alt="Objects" className={className} />
|
||||||
|
)
|
||||||
|
|
||||||
// основные routes
|
// основные routes
|
||||||
const mainNavigationItems: NavigationItem[] = [
|
const mainNavigationItems: NavigationItem[] = [
|
||||||
@@ -82,6 +86,11 @@ const mainNavigationItems: NavigationItem[] = [
|
|||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
label: 'Дашборд'
|
label: 'Дашборд'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
icon: Building3D,
|
||||||
|
label: 'Объекты'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
icon: Bot,
|
icon: Bot,
|
||||||
|
|||||||
504
frontend/components/ui/Sidebar.tsx — копия 3
Normal file
504
frontend/components/ui/Sidebar.tsx — копия 3
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useRouter, usePathname } from 'next/navigation'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import useUIStore from '../../app/store/uiStore'
|
||||||
|
import useNavigationStore from '../../app/store/navigationStore'
|
||||||
|
import { useNavigationService } from '@/services/navigationService'
|
||||||
|
import useUserStore from '../../app/store/userStore'
|
||||||
|
import { signOut } from 'next-auth/react'
|
||||||
|
|
||||||
|
interface NavigationItem {
|
||||||
|
id: number
|
||||||
|
label: string
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
navigationItems?: NavigationItem[]
|
||||||
|
logoSrc?: string
|
||||||
|
userInfo?: {
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
activeItem?: number | null
|
||||||
|
onCustomItemClick?: (itemId: number) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const IconWrapper = ({ src, alt, className }: { src: string; alt: string; className?: string }) => (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const BookOpen = ({ className }: { className?: string }) => (
|
||||||
|
<IconWrapper src="/icons/BookOpen.png" alt="Dashboard" className={className} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const Bot = ({ className }: { className?: string }) => (
|
||||||
|
<IconWrapper src="/icons/Bot.png" alt="Navigation" className={className} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const SquareTerminal = ({ className }: { className?: string }) => (
|
||||||
|
<IconWrapper src="/icons/SquareTerminal.png" alt="Terminal" className={className} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const CircleDot = ({ className }: { className?: string }) => (
|
||||||
|
<IconWrapper src="/icons/CircleDot.png" alt="Sensors" className={className} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const BellDot = ({ className }: { className?: string }) => (
|
||||||
|
<IconWrapper src="/icons/BellDot.png" alt="Notifications" className={className} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const History = ({ className }: { className?: string }) => (
|
||||||
|
<IconWrapper src="/icons/History.png" alt="History" className={className} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const Settings2 = ({ className }: { className?: string }) => (
|
||||||
|
<IconWrapper src="/icons/Settings2.png" alt="Settings" className={className} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const Monitor = ({ className }: { className?: string }) => (
|
||||||
|
<IconWrapper src="/icons/Bot.png" alt="Monitor" className={className} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const Building = ({ className }: { className?: string }) => (
|
||||||
|
<IconWrapper src="/icons/BookOpen.png" alt="Building" className={className} />
|
||||||
|
)
|
||||||
|
|
||||||
|
// основные routes
|
||||||
|
const mainNavigationItems: NavigationItem[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
icon: BookOpen,
|
||||||
|
label: 'Дашборд'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
icon: Bot,
|
||||||
|
label: 'Навигация по зданию'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
icon: History,
|
||||||
|
label: 'История тревог'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
icon: Settings2,
|
||||||
|
label: 'Отчеты'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// суб-меню под "Навигация по зданию"
|
||||||
|
const navigationSubItems: NavigationItem[] = [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
icon: Monitor,
|
||||||
|
label: 'Зоны Мониторинга'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
icon: Building,
|
||||||
|
label: 'Навигация по этажам'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
icon: BellDot,
|
||||||
|
label: 'Уведомления'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
icon: CircleDot,
|
||||||
|
label: 'Сенсоры'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
icon: SquareTerminal,
|
||||||
|
label: 'Список датчиков'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
const Sidebar: React.FC<SidebarProps> = ({
|
||||||
|
logoSrc,
|
||||||
|
userInfo = {
|
||||||
|
name: '—',
|
||||||
|
role: '—'
|
||||||
|
},
|
||||||
|
activeItem: propActiveItem,
|
||||||
|
onCustomItemClick
|
||||||
|
}) => {
|
||||||
|
const navigationService = useNavigationService()
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const [internalActiveItem, setInternalActiveItem] = useState<number | null>(null)
|
||||||
|
const [isHydrated, setIsHydrated] = useState(false)
|
||||||
|
const [manuallyToggled, setManuallyToggled] = useState(false)
|
||||||
|
const activeItem = propActiveItem !== undefined ? propActiveItem : internalActiveItem
|
||||||
|
const {
|
||||||
|
isSidebarCollapsed: isCollapsed,
|
||||||
|
toggleSidebar,
|
||||||
|
isNavigationSubMenuExpanded: showNavigationSubItems,
|
||||||
|
setNavigationSubMenuExpanded: setShowNavigationSubItems,
|
||||||
|
toggleNavigationSubMenu
|
||||||
|
} = useUIStore()
|
||||||
|
const { user, logout } = useUserStore()
|
||||||
|
|
||||||
|
const roleLabelMap: Record<string, string> = {
|
||||||
|
engineer: 'Инженер',
|
||||||
|
operator: 'Оператор',
|
||||||
|
admin: 'Администратор',
|
||||||
|
}
|
||||||
|
const fullName = [user?.name, user?.surname].filter(Boolean).join(' ').trim()
|
||||||
|
|
||||||
|
const uiUserInfo = {
|
||||||
|
name: fullName || user?.login || userInfo?.name || '—',
|
||||||
|
role: roleLabelMap[(user?.account_type ?? '').toLowerCase()] || userInfo?.role || '—',
|
||||||
|
avatar: user?.image || userInfo?.avatar,
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Logout request failed:', e)
|
||||||
|
} finally {
|
||||||
|
logout()
|
||||||
|
await signOut({ redirect: true, callbackUrl: '/login' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
openMonitoring,
|
||||||
|
openFloorNavigation,
|
||||||
|
openNotifications,
|
||||||
|
openSensors,
|
||||||
|
openListOfDetectors,
|
||||||
|
closeSensors,
|
||||||
|
closeListOfDetectors,
|
||||||
|
closeMonitoring,
|
||||||
|
closeFloorNavigation,
|
||||||
|
closeNotifications,
|
||||||
|
showMonitoring,
|
||||||
|
showFloorNavigation,
|
||||||
|
showNotifications,
|
||||||
|
showSensors,
|
||||||
|
showListOfDetectors
|
||||||
|
} = useNavigationStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsHydrated(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Чек если суб-меню активны
|
||||||
|
const isNavigationSubItemActive = activeItem && [3, 4, 5, 6, 7].includes(activeItem)
|
||||||
|
const shouldShowNavigationAsActive = activeItem === 2 || isNavigationSubItemActive
|
||||||
|
|
||||||
|
// Авто-расткрытие меню, если суб-меню стало активным (только если не было ручного переключения)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNavigationSubItemActive && !showNavigationSubItems && !manuallyToggled) {
|
||||||
|
setShowNavigationSubItems(true)
|
||||||
|
}
|
||||||
|
}, [isNavigationSubItemActive, showNavigationSubItems, manuallyToggled, setShowNavigationSubItems])
|
||||||
|
|
||||||
|
const handleItemClick = (itemId: number) => {
|
||||||
|
let handled = false
|
||||||
|
|
||||||
|
// Управление суб-меню через navigationStore (суб-меню - работают как отдельные элементы, но не страницы)
|
||||||
|
switch (itemId) {
|
||||||
|
case 2:
|
||||||
|
if (pathname !== '/navigation') {
|
||||||
|
router.push('/navigation')
|
||||||
|
}
|
||||||
|
handled = true
|
||||||
|
break
|
||||||
|
case 3: // Monitoring
|
||||||
|
if (pathname !== '/navigation') {
|
||||||
|
router.push('/navigation')
|
||||||
|
setTimeout(() => openMonitoring(), 100)
|
||||||
|
} else if (showMonitoring) {
|
||||||
|
closeMonitoring()
|
||||||
|
} else {
|
||||||
|
openMonitoring()
|
||||||
|
}
|
||||||
|
handled = true
|
||||||
|
break
|
||||||
|
case 4: // Floor Navigation
|
||||||
|
if (pathname !== '/navigation') {
|
||||||
|
router.push('/navigation')
|
||||||
|
setTimeout(() => openFloorNavigation(), 100)
|
||||||
|
} else if (showFloorNavigation) {
|
||||||
|
closeFloorNavigation()
|
||||||
|
} else {
|
||||||
|
openFloorNavigation()
|
||||||
|
}
|
||||||
|
handled = true
|
||||||
|
break
|
||||||
|
case 5: // Notifications
|
||||||
|
if (pathname !== '/navigation') {
|
||||||
|
router.push('/navigation')
|
||||||
|
setTimeout(() => openNotifications(), 100)
|
||||||
|
} else if (showNotifications) {
|
||||||
|
closeNotifications()
|
||||||
|
} else {
|
||||||
|
openNotifications()
|
||||||
|
}
|
||||||
|
handled = true
|
||||||
|
break
|
||||||
|
case 6: // Sensors
|
||||||
|
if (pathname !== '/navigation') {
|
||||||
|
router.push('/navigation')
|
||||||
|
setTimeout(() => openSensors(), 100)
|
||||||
|
} else if (showSensors) {
|
||||||
|
closeSensors()
|
||||||
|
} else {
|
||||||
|
openSensors()
|
||||||
|
}
|
||||||
|
handled = true
|
||||||
|
break
|
||||||
|
case 7: // Detector List
|
||||||
|
if (pathname !== '/navigation') {
|
||||||
|
router.push('/navigation')
|
||||||
|
setTimeout(() => openListOfDetectors(), 100)
|
||||||
|
} else if (showListOfDetectors) {
|
||||||
|
closeListOfDetectors()
|
||||||
|
} else {
|
||||||
|
openListOfDetectors()
|
||||||
|
}
|
||||||
|
handled = true
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
// Для остального используем routes
|
||||||
|
if (navigationService) {
|
||||||
|
handled = navigationService.handleSidebarItemClick(itemId, pathname)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
if (propActiveItem === undefined) {
|
||||||
|
setInternalActiveItem(itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onCustomItemClick) {
|
||||||
|
onCustomItemClick(itemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`flex flex-col items-start gap-6 relative bg-[#161824] transition-all duration-300 h-screen ${
|
||||||
|
isCollapsed ? 'w-16' : 'w-64'
|
||||||
|
}`}
|
||||||
|
role="navigation"
|
||||||
|
aria-label="Main navigation"
|
||||||
|
>
|
||||||
|
<header className="flex items-center gap-2 pt-2 pb-2 px-4 relative self-stretch w-full flex-[0_0_auto] bg-[#161824]">
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="relative">
|
||||||
|
<Image
|
||||||
|
className="w-auto h-[33px]"
|
||||||
|
alt="AerBIM Monitor Logo"
|
||||||
|
src={logoSrc || "/icons/logo.png"}
|
||||||
|
width={169}
|
||||||
|
height={33}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className="flex flex-col items-end gap-2 relative flex-1 self-stretch w-full grow">
|
||||||
|
<div className="flex flex-col items-end gap-2 px-4 py-2 relative self-stretch w-full flex-[0_0_auto]">
|
||||||
|
<ul className="flex flex-col items-start gap-3 relative self-stretch w-full flex-[0_0_auto]" role="list">
|
||||||
|
{mainNavigationItems.map((item) => {
|
||||||
|
const IconComponent = item.icon
|
||||||
|
const isActive = item.id === 2 ? shouldShowNavigationAsActive : activeItem === item.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.id} className="flex-col flex items-center relative self-stretch w-full" role="listitem">
|
||||||
|
<button
|
||||||
|
className={`gap-2 pt-2 pr-2 pb-2 pl-2 rounded-md flex h-9 items-center relative self-stretch w-full transition-all duration-200 hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset ${
|
||||||
|
isActive ? 'bg-gradient-to-r from-blue-600 to-cyan-500 shadow-lg shadow-blue-500/30' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleItemClick(item.id)}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<IconComponent
|
||||||
|
className="!relative !w-5 !h-5 text-white"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span className="flex-1 [font-family:'Inter-Regular',Helvetica] font-normal text-white text-sm leading-[14px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical] text-left">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.id === 2 && !isCollapsed && (
|
||||||
|
// Закрыть все суб-меню при закрытии главного окна
|
||||||
|
<div
|
||||||
|
className="p-1.5 hover:bg-gray-600 rounded-md transition-colors duration-200 cursor-pointer bg-gray-700/50 border border-gray-600/50"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setManuallyToggled(true)
|
||||||
|
if (showNavigationSubItems || isNavigationSubItemActive) {
|
||||||
|
closeMonitoring()
|
||||||
|
closeFloorNavigation()
|
||||||
|
closeNotifications()
|
||||||
|
}
|
||||||
|
toggleNavigationSubMenu()
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setManuallyToggled(true)
|
||||||
|
if (showNavigationSubItems || isNavigationSubItemActive) {
|
||||||
|
closeMonitoring()
|
||||||
|
closeFloorNavigation()
|
||||||
|
closeNotifications()
|
||||||
|
}
|
||||||
|
toggleNavigationSubMenu()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={isHydrated ? (showNavigationSubItems || isNavigationSubItemActive ? 'Collapse navigation menu' : 'Expand navigation menu') : 'Toggle navigation menu'}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`!relative !w-4 !h-4 text-white transition-transform duration-200 drop-shadow-sm ${
|
||||||
|
isHydrated && (showNavigationSubItems || isNavigationSubItemActive) ? 'rotate-90' : ''
|
||||||
|
}`}
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Суб-меню */}
|
||||||
|
{item.id === 2 && !isCollapsed && (showNavigationSubItems || isNavigationSubItemActive) && (
|
||||||
|
<ul className="flex flex-col items-start gap-1 mt-1 ml-6 relative w-full" role="list">
|
||||||
|
{navigationSubItems.map((subItem) => {
|
||||||
|
const SubIconComponent = subItem.icon
|
||||||
|
const isSubActive = activeItem === subItem.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={subItem.id} className="flex-col flex h-8 items-center relative self-stretch w-full" role="listitem">
|
||||||
|
<button
|
||||||
|
className={`gap-2 pt-1.5 pr-2 pb-1.5 pl-2 rounded-md flex h-8 items-center relative self-stretch w-full transition-all duration-200 hover:bg-gray-600 focus:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset ${
|
||||||
|
isSubActive ? 'bg-gradient-to-r from-blue-600 to-cyan-500 shadow-lg shadow-blue-500/30' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleItemClick(subItem.id)}
|
||||||
|
aria-current={isSubActive ? 'page' : undefined}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<SubIconComponent
|
||||||
|
className="!relative !w-4 !h-4 text-gray-300"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 [font-family:'Inter-Regular',Helvetica] font-normal text-gray-300 text-sm leading-[14px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical] text-left">
|
||||||
|
{subItem.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
className="!relative !w-8 !h-8 p-1.5 rounded-lg hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-200 bg-gray-800/60 border border-gray-600/40 shadow-lg hover:shadow-xl"
|
||||||
|
onClick={() => {
|
||||||
|
// Убираем суб-меню перед сворачиванием сайдбара
|
||||||
|
if (showNavigationSubItems) {
|
||||||
|
setShowNavigationSubItems(false)
|
||||||
|
setManuallyToggled(true)
|
||||||
|
|
||||||
|
closeMonitoring()
|
||||||
|
closeFloorNavigation()
|
||||||
|
closeNotifications()
|
||||||
|
}
|
||||||
|
// Убираем сайд-бар
|
||||||
|
toggleSidebar()
|
||||||
|
}}
|
||||||
|
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg className={`!relative !w-5 !h-5 text-white transition-transform duration-200 drop-shadow-sm ${isCollapsed ? 'rotate-180' : ''}`} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<footer className="flex w-64 items-center gap-2 pt-2 pr-2 pb-2 pl-2 mt-auto bg-[#161824]">
|
||||||
|
<div className="inline-flex flex-[0_0_auto] items-center gap-2 relative rounded-md">
|
||||||
|
<div className="inline-flex items-center relative flex-[0_0_auto]">
|
||||||
|
<div
|
||||||
|
className="relative w-8 h-8 rounded-lg bg-white"
|
||||||
|
role="img"
|
||||||
|
aria-label="User avatar"
|
||||||
|
>
|
||||||
|
{uiUserInfo.avatar && (
|
||||||
|
<Image
|
||||||
|
src={uiUserInfo.avatar}
|
||||||
|
alt="User avatar"
|
||||||
|
className="w-full h-full rounded-lg object-cover"
|
||||||
|
fill
|
||||||
|
sizes="32px"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex p-2 flex-1 grow items-center gap-2 relative rounded-md">
|
||||||
|
<div className="flex flex-col items-start justify-center gap-0.5 relative flex-1 grow">
|
||||||
|
<div className="self-stretch mt-[-1.00px] [font-family:'Inter-SemiBold',Helvetica] font-semibold text-white text-sm leading-[14px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical]">
|
||||||
|
{uiUserInfo.name}
|
||||||
|
</div>
|
||||||
|
<div className="self-stretch [font-family:'Inter-Regular',Helvetica] font-normal text-[#71717a] text-[10px] leading-[10px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical]">
|
||||||
|
{uiUserInfo.role}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="relative w-4 h-4 aspect-[1] p-1 rounded hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200"
|
||||||
|
aria-label="Logout"
|
||||||
|
title="Выйти"
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M10 17l-5-5 5-5v3h8v4h-8v3z" />
|
||||||
|
<path d="M20 3h-8v2h8v14h-8v2h8c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sidebar
|
||||||
@@ -158,3 +158,97 @@ export function aggregateChartDataByDaysAverage(
|
|||||||
|
|
||||||
return dailyData
|
return dailyData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерфейс для данных с разделением по типам событий
|
||||||
|
*/
|
||||||
|
export interface SeverityChartDataPoint {
|
||||||
|
timestamp: string
|
||||||
|
critical: number
|
||||||
|
warning: number
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Агрегирует данные тревог по дням с разделением по severity
|
||||||
|
* @param alerts - Массив тревог с полями timestamp и severity
|
||||||
|
* @param timePeriod - Период в часах ('24', '72', '168', '720')
|
||||||
|
* @returns Агрегированные данные с разделением по critical/warning
|
||||||
|
*/
|
||||||
|
export function aggregateAlertsBySeverity(
|
||||||
|
alerts: Array<{ timestamp?: string; created_at?: string; severity?: string }>,
|
||||||
|
timePeriod: string
|
||||||
|
): SeverityChartDataPoint[] {
|
||||||
|
if (!Array.isArray(alerts) || alerts.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем по дням
|
||||||
|
const dailyMap = new Map<string, { critical: number; warning: number; date: Date }>()
|
||||||
|
|
||||||
|
alerts.forEach(alert => {
|
||||||
|
const timestampField = alert.timestamp || alert.created_at
|
||||||
|
if (!timestampField) return
|
||||||
|
|
||||||
|
const date = new Date(timestampField)
|
||||||
|
const dateKey = date.toISOString().split('T')[0]
|
||||||
|
|
||||||
|
if (!dailyMap.has(dateKey)) {
|
||||||
|
dailyMap.set(dateKey, { critical: 0, warning: 0, date })
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = dailyMap.get(dateKey)!
|
||||||
|
|
||||||
|
if (alert.severity === 'critical') {
|
||||||
|
entry.critical += 1
|
||||||
|
} else if (alert.severity === 'warning') {
|
||||||
|
entry.warning += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Преобразуем в массив и сортируем по дате
|
||||||
|
const dailyData = Array.from(dailyMap.entries())
|
||||||
|
.sort((a, b) => new Date(a[0]).getTime() - new Date(b[0]).getTime())
|
||||||
|
.map(([dateKey, data]) => {
|
||||||
|
const date = new Date(dateKey)
|
||||||
|
|
||||||
|
// Форматируем подпись в зависимости от периода
|
||||||
|
let label = ''
|
||||||
|
if (timePeriod === '24') {
|
||||||
|
// День - показываем время
|
||||||
|
label = date.toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit'
|
||||||
|
})
|
||||||
|
} else if (timePeriod === '72') {
|
||||||
|
// 3 дня - показываем день недели и дату
|
||||||
|
label = date.toLocaleDateString('ru-RU', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit'
|
||||||
|
})
|
||||||
|
} else if (timePeriod === '168') {
|
||||||
|
// Неделя - показываем день недели
|
||||||
|
label = date.toLocaleDateString('ru-RU', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: '2-digit'
|
||||||
|
})
|
||||||
|
} else if (timePeriod === '720') {
|
||||||
|
// Месяц - показываем дату
|
||||||
|
label = date.toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: date.toISOString(),
|
||||||
|
critical: data.critical,
|
||||||
|
warning: data.warning,
|
||||||
|
label
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return dailyData
|
||||||
|
}
|
||||||
|
|||||||
160
frontend/lib/chartDataAggregator — копия.ts
Normal file
160
frontend/lib/chartDataAggregator — копия.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Утилита для агрегации часовых данных в дневные
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ChartDataPoint {
|
||||||
|
timestamp: string
|
||||||
|
value: number
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Агрегирует часовые данные в дневные на основе периода
|
||||||
|
* @param hourlyData - Массив часовых данных
|
||||||
|
* @param timePeriod - Период в часах ('24', '72', '168', '720')
|
||||||
|
* @returns Агрегированные дневные данные
|
||||||
|
*/
|
||||||
|
export function aggregateChartDataByDays(
|
||||||
|
hourlyData: ChartDataPoint[],
|
||||||
|
timePeriod: string
|
||||||
|
): ChartDataPoint[] {
|
||||||
|
if (!Array.isArray(hourlyData) || hourlyData.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для периода в 24 часа - возвращаем часовые данные как есть
|
||||||
|
if (timePeriod === '24') {
|
||||||
|
return hourlyData.map(d => ({
|
||||||
|
...d,
|
||||||
|
label: d.label || new Date(d.timestamp).toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для остальных периодов - агрегируем по дням
|
||||||
|
const dailyMap = new Map<string, { sum: number; count: number; date: Date }>()
|
||||||
|
|
||||||
|
hourlyData.forEach(point => {
|
||||||
|
const date = new Date(point.timestamp)
|
||||||
|
// Получаем дату в формате YYYY-MM-DD
|
||||||
|
const dateKey = date.toISOString().split('T')[0]
|
||||||
|
|
||||||
|
if (!dailyMap.has(dateKey)) {
|
||||||
|
dailyMap.set(dateKey, { sum: 0, count: 0, date })
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = dailyMap.get(dateKey)!
|
||||||
|
entry.sum += point.value
|
||||||
|
entry.count += 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// Преобразуем в массив и сортируем по дате
|
||||||
|
const dailyData = Array.from(dailyMap.entries())
|
||||||
|
.sort((a, b) => new Date(a[0]).getTime() - new Date(b[0]).getTime())
|
||||||
|
.map(([dateKey, data]) => {
|
||||||
|
const date = new Date(dateKey)
|
||||||
|
|
||||||
|
// Форматируем подпись в зависимости от периода
|
||||||
|
let label = ''
|
||||||
|
if (timePeriod === '72') {
|
||||||
|
// 3 дня - показываем день недели и дату
|
||||||
|
label = date.toLocaleDateString('ru-RU', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit'
|
||||||
|
})
|
||||||
|
} else if (timePeriod === '168') {
|
||||||
|
// Неделя - показываем день недели
|
||||||
|
label = date.toLocaleDateString('ru-RU', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: '2-digit'
|
||||||
|
})
|
||||||
|
} else if (timePeriod === '720') {
|
||||||
|
// Месяц - показываем дату
|
||||||
|
label = date.toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: date.toISOString(),
|
||||||
|
value: data.sum,
|
||||||
|
label
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return dailyData
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает среднее значение за день (для альтернативной агрегации)
|
||||||
|
*/
|
||||||
|
export function aggregateChartDataByDaysAverage(
|
||||||
|
hourlyData: ChartDataPoint[],
|
||||||
|
timePeriod: string
|
||||||
|
): ChartDataPoint[] {
|
||||||
|
if (!Array.isArray(hourlyData) || hourlyData.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timePeriod === '24') {
|
||||||
|
return hourlyData.map(d => ({
|
||||||
|
...d,
|
||||||
|
label: d.label || new Date(d.timestamp).toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyMap = new Map<string, { sum: number; count: number; date: Date }>()
|
||||||
|
|
||||||
|
hourlyData.forEach(point => {
|
||||||
|
const date = new Date(point.timestamp)
|
||||||
|
const dateKey = date.toISOString().split('T')[0]
|
||||||
|
|
||||||
|
if (!dailyMap.has(dateKey)) {
|
||||||
|
dailyMap.set(dateKey, { sum: 0, count: 0, date })
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = dailyMap.get(dateKey)!
|
||||||
|
entry.sum += point.value
|
||||||
|
entry.count += 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const dailyData = Array.from(dailyMap.entries())
|
||||||
|
.sort((a, b) => new Date(a[0]).getTime() - new Date(b[0]).getTime())
|
||||||
|
.map(([dateKey, data]) => {
|
||||||
|
const date = new Date(dateKey)
|
||||||
|
|
||||||
|
let label = ''
|
||||||
|
if (timePeriod === '72') {
|
||||||
|
label = date.toLocaleDateString('ru-RU', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit'
|
||||||
|
})
|
||||||
|
} else if (timePeriod === '168') {
|
||||||
|
label = date.toLocaleDateString('ru-RU', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: '2-digit'
|
||||||
|
})
|
||||||
|
} else if (timePeriod === '720') {
|
||||||
|
label = date.toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: date.toISOString(),
|
||||||
|
value: Math.round(data.sum / data.count), // Среднее значение
|
||||||
|
label
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return dailyData
|
||||||
|
}
|
||||||
BIN
frontend/public/icons/Building3D.png
Normal file
BIN
frontend/public/icons/Building3D.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 MiB |
BIN
frontend/public/icons/Floor3D.png
Normal file
BIN
frontend/public/icons/Floor3D.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
5
frontend/public/icons/Map.svg
Normal file
5
frontend/public/icons/Map.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"></polygon>
|
||||||
|
<line x1="9" y1="3" x2="9" y2="18"></line>
|
||||||
|
<line x1="15" y1="6" x2="15" y2="21"></line>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 347 B |
@@ -15,7 +15,8 @@ export const SIDEBAR_ITEM_MAP = {
|
|||||||
1: { type: 'route', value: MainRoutes.DASHBOARD },
|
1: { type: 'route', value: MainRoutes.DASHBOARD },
|
||||||
2: { type: 'route', value: MainRoutes.NAVIGATION },
|
2: { type: 'route', value: MainRoutes.NAVIGATION },
|
||||||
8: { type: 'route', value: MainRoutes.ALERTS },
|
8: { type: 'route', value: MainRoutes.ALERTS },
|
||||||
9: { type: 'route', value: MainRoutes.REPORTS }
|
9: { type: 'route', value: MainRoutes.REPORTS },
|
||||||
|
10: { type: 'route', value: MainRoutes.OBJECTS }
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export class NavigationService {
|
export class NavigationService {
|
||||||
|
|||||||
94
frontend/services/navigationService — копия.ts
Normal file
94
frontend/services/navigationService — копия.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useNavigationStore from '@/app/store/navigationStore'
|
||||||
|
import type { NavigationStore } from '@/app/store/navigationStore'
|
||||||
|
|
||||||
|
export enum MainRoutes {
|
||||||
|
DASHBOARD = '/dashboard',
|
||||||
|
NAVIGATION = '/navigation',
|
||||||
|
ALERTS = '/alerts',
|
||||||
|
REPORTS = '/reports',
|
||||||
|
OBJECTS = '/objects'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SIDEBAR_ITEM_MAP = {
|
||||||
|
1: { type: 'route', value: MainRoutes.DASHBOARD },
|
||||||
|
2: { type: 'route', value: MainRoutes.NAVIGATION },
|
||||||
|
8: { type: 'route', value: MainRoutes.ALERTS },
|
||||||
|
9: { type: 'route', value: MainRoutes.REPORTS }
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export class NavigationService {
|
||||||
|
private router!: ReturnType<typeof useRouter>
|
||||||
|
private navigationStore!: NavigationStore
|
||||||
|
private initialized = false
|
||||||
|
|
||||||
|
init(router: ReturnType<typeof useRouter>, navigationStore: NavigationStore) {
|
||||||
|
if (this.initialized) {
|
||||||
|
return // Предотвращаем повторную инициализацию
|
||||||
|
}
|
||||||
|
this.router = router
|
||||||
|
this.navigationStore = navigationStore
|
||||||
|
this.initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
isInitialized(): boolean {
|
||||||
|
return this.initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToRoute(route: MainRoutes) {
|
||||||
|
// Убираем подменю перед переходом на другую страницу
|
||||||
|
if (route !== MainRoutes.NAVIGATION) {
|
||||||
|
this.navigationStore.setCurrentSubmenu(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.push(route)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSidebarItemClick(itemId: number, currentPath: string): boolean {
|
||||||
|
if (!this.initialized) {
|
||||||
|
console.error('NavigationService not initialized!')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = SIDEBAR_ITEM_MAP[itemId as keyof typeof SIDEBAR_ITEM_MAP]
|
||||||
|
|
||||||
|
if (!mapping) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapping.type === 'route') {
|
||||||
|
this.navigateToRoute(mapping.value as MainRoutes)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack() {
|
||||||
|
this.navigationStore.goBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
selectObjectAndGoToDashboard(objectId: string, objectTitle: string) {
|
||||||
|
this.navigationStore.setCurrentObject(objectId, objectTitle)
|
||||||
|
// Проверяем, что подменю закрыто перед навигацией
|
||||||
|
this.navigationStore.setCurrentSubmenu(null)
|
||||||
|
const url = `${MainRoutes.DASHBOARD}?objectId=${encodeURIComponent(objectId)}&objectTitle=${encodeURIComponent(objectTitle)}`
|
||||||
|
this.router.push(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const navigationService = new NavigationService()
|
||||||
|
|
||||||
|
export function useNavigationService() {
|
||||||
|
const router = useRouter()
|
||||||
|
const navigationStore = useNavigationStore()
|
||||||
|
|
||||||
|
React.useMemo(() => {
|
||||||
|
if (!navigationService.isInitialized()) {
|
||||||
|
navigationService.init(router, navigationStore)
|
||||||
|
}
|
||||||
|
}, [router, navigationStore])
|
||||||
|
|
||||||
|
return navigationService
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user