diff --git a/frontend/app/(protected)/navigation/page.tsx — копия 2 b/frontend/app/(protected)/navigation/page.tsx — копия 2 new file mode 100644 index 0000000..1c02517 --- /dev/null +++ b/frontend/app/(protected)/navigation/page.tsx — копия 2 @@ -0,0 +1,658 @@ +'use client' + +import React, { useEffect, useCallback, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import Image from 'next/image' +import Sidebar from '../../../components/ui/Sidebar' +import AnimatedBackground from '../../../components/ui/AnimatedBackground' +import useNavigationStore from '../../store/navigationStore' +import Monitoring from '../../../components/navigation/Monitoring' +import FloorNavigation from '../../../components/navigation/FloorNavigation' +import DetectorMenu from '../../../components/navigation/DetectorMenu' +import ListOfDetectors from '../../../components/navigation/ListOfDetectors' +import Sensors from '../../../components/navigation/Sensors' +import AlertMenu from '../../../components/navigation/AlertMenu' +import Notifications from '../../../components/notifications/Notifications' +import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo' +import dynamic from 'next/dynamic' +import type { ModelViewerProps } from '../../../components/model/ModelViewer' +import * as statusColors from '../../../lib/statusColors' + +const ModelViewer = dynamic(() => import('../../../components/model/ModelViewer'), { + ssr: false, + loading: () => ( +
+
Загрузка 3D-модуля…
+
+ ), + }) + +interface DetectorType { + detector_id: number + name: string + serial_number: string + object: string + status: string + checked: boolean + type: string + detector_type: string + location: string + floor: number + notifications: Array<{ + id: number + type: string + message: string + timestamp: string + acknowledged: boolean + priority: string + }> +} + +interface NotificationType { + id: number + detector_id: number + detector_name: string + type: string + status: string + message: string + timestamp: string + location: string + object: string + acknowledged: boolean + priority: string +} + +interface AlertType { + id: number + detector_id: number + detector_name: string + type: string + status: string + message: string + timestamp: string + location: string + object: string + acknowledged: boolean + priority: string +} + +const NavigationPage: React.FC = () => { + const router = useRouter() + const searchParams = useSearchParams() + const { + currentObject, + setCurrentObject, + showMonitoring, + showFloorNavigation, + showNotifications, + showListOfDetectors, + showSensors, + selectedDetector, + showDetectorMenu, + selectedNotification, + showNotificationDetectorInfo, + selectedAlert, + showAlertMenu, + closeMonitoring, + closeFloorNavigation, + closeNotifications, + closeListOfDetectors, + closeSensors, + setSelectedDetector, + setShowDetectorMenu, + setSelectedNotification, + setShowNotificationDetectorInfo, + setSelectedAlert, + setShowAlertMenu, + showSensorHighlights, + toggleSensorHighlights + } = useNavigationStore() + + const [detectorsData, setDetectorsData] = useState<{ detectors: Record }>({ detectors: {} }) + const [detectorsError, setDetectorsError] = useState(null) + const [modelError, setModelError] = useState(null) + const [isModelReady, setIsModelReady] = useState(false) + const [focusedSensorId, setFocusedSensorId] = useState(null) + const [highlightAllSensors, setHighlightAllSensors] = useState(false) + const sensorStatusMap = React.useMemo(() => { + // Создаём карту статусов всегда для отображения цветов датчиков + const map: Record = {} + Object.values(detectorsData.detectors).forEach(d => { + if (d.serial_number && d.status) { + map[String(d.serial_number).trim()] = d.status + + // Логируем 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(urlModelPath || '') + + + const handleModelLoaded = useCallback(() => { + setIsModelReady(true) + setModelError(null) + }, []) + + const handleModelError = useCallback((error: string) => { + console.error('[NavigationPage] Model loading error:', error) + setModelError(error) + setIsModelReady(false) + }, []) + + useEffect(() => { + if (selectedModelPath) { + setIsModelReady(false); + setModelError(null); + // Сохраняем выбранную модель в URL для восстановления при возврате + const params = new URLSearchParams(searchParams.toString()); + params.set('modelPath', selectedModelPath); + window.history.replaceState(null, '', `?${params.toString()}`); + } + }, [selectedModelPath, searchParams]); + + useEffect(() => { + if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) { + setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined) + } + }, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject]) + + // Восстановление выбранной модели из URL при загрузке страницы + useEffect(() => { + if (urlModelPath && !selectedModelPath) { + setSelectedModelPath(urlModelPath); + } + }, [urlModelPath, selectedModelPath]) + + useEffect(() => { + const loadDetectors = async () => { + try { + setDetectorsError(null) + + // Если есть 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 + 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 ( +
+ +
+ +
+ +
+ + {showMonitoring && ( +
+
+ { + console.log('[NavigationPage] Model selected:', path); + setSelectedModelPath(path) + setModelError(null) + setIsModelReady(false) + }} + /> +
+
+ )} + + {showFloorNavigation && ( +
+
+ +
+
+ )} + + {showNotifications && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showListOfDetectors && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showSensors && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showNotifications && showNotificationDetectorInfo && selectedNotification && (() => { + const detectorData = Object.values(detectorsData.detectors).find( + detector => detector.detector_id === selectedNotification.detector_id + ); + return detectorData ? ( +
+
+ +
+
+ ) : null; + })()} + + {showFloorNavigation && showDetectorMenu && selectedDetector && ( + null + )} + +
+
+
+ + +
+
+
+ +
+
+ {modelError ? ( + <> + {console.log('[NavigationPage] Rendering error message, modelError:', modelError)} +
+
+
+ Ошибка загрузки 3D модели +
+
+ {modelError} +
+
+ Используйте навигацию по этажам для просмотра детекторов +
+
+
+ + ) : !selectedModelPath ? ( +
+
+ AerBIM HT Monitor +
+ Выберите модель для отображения +
+
+
+ ) : ( + { + console.log('[NavigationPage] Model selected:', path); + setSelectedModelPath(path) + setModelError(null) + setIsModelReady(false) + }} + onModelLoaded={handleModelLoaded} + onError={handleModelError} + activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null} + focusSensorId={focusedSensorId} + highlightAllSensors={showSensorHighlights && highlightAllSensors} + sensorStatusMap={sensorStatusMap} + isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors} + onSensorPick={handleSensorSelection} + renderOverlay={({ anchor }) => ( + <> + {selectedAlert && showAlertMenu && anchor ? ( + + ) : selectedDetector && showDetectorMenu && anchor ? ( + + ) : null} + + )} + /> + )} +
+
+
+
+ ) +} + +export default NavigationPage diff --git a/frontend/app/(protected)/navigation/page.tsx — копия 3 b/frontend/app/(protected)/navigation/page.tsx — копия 3 new file mode 100644 index 0000000..6250c82 --- /dev/null +++ b/frontend/app/(protected)/navigation/page.tsx — копия 3 @@ -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(() => import('../../../components/model/ModelViewer'), { + ssr: false, + loading: () => ( +
+
Загрузка 3D-модуля…
+
+ ), + }) + +interface DetectorType { + detector_id: number + name: string + serial_number: string + object: string + status: string + checked: boolean + type: string + detector_type: string + location: string + floor: number + notifications: Array<{ + id: number + type: string + message: string + timestamp: string + acknowledged: boolean + priority: string + }> +} + +interface NotificationType { + id: number + detector_id: number + detector_name: string + type: string + status: string + message: string + timestamp: string + location: string + object: string + acknowledged: boolean + priority: string +} + +interface AlertType { + id: number + detector_id: number + detector_name: string + type: string + status: string + message: string + timestamp: string + location: string + object: string + acknowledged: boolean + priority: string +} + +const NavigationPage: React.FC = () => { + const router = useRouter() + const searchParams = useSearchParams() + const { + currentObject, + setCurrentObject, + showMonitoring, + showFloorNavigation, + showNotifications, + showListOfDetectors, + showSensors, + selectedDetector, + showDetectorMenu, + selectedNotification, + showNotificationDetectorInfo, + selectedAlert, + showAlertMenu, + closeMonitoring, + closeFloorNavigation, + closeNotifications, + closeListOfDetectors, + closeSensors, + setSelectedDetector, + setShowDetectorMenu, + setSelectedNotification, + setShowNotificationDetectorInfo, + setSelectedAlert, + setShowAlertMenu, + showSensorHighlights, + toggleSensorHighlights + } = useNavigationStore() + + const [detectorsData, setDetectorsData] = useState<{ detectors: Record }>({ detectors: {} }) + const [detectorsError, setDetectorsError] = useState(null) + const [modelError, setModelError] = useState(null) + const [isModelReady, setIsModelReady] = useState(false) + const [focusedSensorId, setFocusedSensorId] = useState(null) + const [highlightAllSensors, setHighlightAllSensors] = useState(false) + const sensorStatusMap = React.useMemo(() => { + // Создаём карту статусов всегда для отображения цветов датчиков + const map: Record = {} + Object.values(detectorsData.detectors).forEach(d => { + if (d.serial_number && d.status) { + map[String(d.serial_number).trim()] = d.status + + // Логируем 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('') + + + 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 + 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 ( +
+ +
+ +
+ +
+ + {showMonitoring && ( +
+
+ { + console.log('[NavigationPage] Model selected:', path); + setSelectedModelPath(path) + setModelError(null) + setIsModelReady(false) + }} + /> +
+
+ )} + + {showFloorNavigation && ( +
+
+ +
+
+ )} + + {showNotifications && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showListOfDetectors && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showSensors && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showNotifications && showNotificationDetectorInfo && selectedNotification && (() => { + const detectorData = Object.values(detectorsData.detectors).find( + detector => detector.detector_id === selectedNotification.detector_id + ); + return detectorData ? ( +
+
+ +
+
+ ) : null; + })()} + + {showFloorNavigation && showDetectorMenu && selectedDetector && ( + null + )} + +
+
+
+ + +
+
+
+ +
+
+ {modelError ? ( + <> + {console.log('[NavigationPage] Rendering error message, modelError:', modelError)} +
+
+
+ Ошибка загрузки 3D модели +
+
+ {modelError} +
+
+ Используйте навигацию по этажам для просмотра детекторов +
+
+
+ + ) : !selectedModelPath ? ( +
+
+ AerBIM HT Monitor +
+ Выберите модель для отображения +
+
+
+ ) : ( + { + console.log('[NavigationPage] Model selected:', path); + setSelectedModelPath(path) + setModelError(null) + setIsModelReady(false) + }} + onModelLoaded={handleModelLoaded} + onError={handleModelError} + activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null} + focusSensorId={focusedSensorId} + highlightAllSensors={showSensorHighlights && highlightAllSensors} + sensorStatusMap={sensorStatusMap} + isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors} + onSensorPick={handleSensorSelection} + renderOverlay={({ anchor }) => ( + <> + {selectedAlert && showAlertMenu && anchor ? ( + + ) : selectedDetector && showDetectorMenu && anchor ? ( + + ) : null} + + )} + /> + )} +
+
+
+
+ ) +} + +export default NavigationPage diff --git a/frontend/app/store/navigationStore.ts b/frontend/app/store/navigationStore.ts index a43c9ed..3af4cbf 100644 --- a/frontend/app/store/navigationStore.ts +++ b/frontend/app/store/navigationStore.ts @@ -119,7 +119,7 @@ export interface NavigationStore { getActiveSidebarItem: () => number // Навигация к датчику на 3D-модели - navigateToSensor: (sensorSerialNumber: string, floor: number | null, viewType: 'building' | 'floor') => Promise + navigateToSensor: (sensorSerialNumber: string, floor: number | null, viewType: 'building' | 'floor') => Promise<{ sensorSerialNumber: string; modelPath: string } | null> } const useNavigationStore = create()( @@ -399,40 +399,54 @@ const useNavigationStore = create()( 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 + // Для вида на этаже - ищем зону, где есть этот датчик (исключая 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 + 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 sensor in zone:', zone.name, 'sensors:', zone.sensors.length) + console.log('[navigateToSensor] ✅ FOUND! Selected zone:', zone.name, 'zoneId:', zone.id, 'model_path:', zone.model_path) 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) + } catch (e) { + console.error('[navigateToSensor] Failed to load detectors for zone:', zone.id, e) + continue } } // Fallback на общий вид, если не нашли зону этажа if (!targetZone) { - console.warn(`[navigateToSensor] No zone found with sensor ${sensorSerialNumber} or floor ${floor}, falling back to building view`) + console.warn(`[navigateToSensor] No floor zone found with sensor ${sensorSerialNumber}, falling back to building view`) targetZone = currentZones[0] } } @@ -468,8 +482,11 @@ const useNavigationStore = create()( zoneId: targetZone.id }) - // Возвращаем serial_number для установки focusedSensorId в компоненте - return sensorSerialNumber + // Возвращаем объект с sensorSerialNumber и modelPath + return { + sensorSerialNumber, + modelPath: targetZone.model_path + } } }), { diff --git a/frontend/app/store/navigationStore — копия 2.ts b/frontend/app/store/navigationStore — копия 2.ts deleted file mode 100644 index 49ec904..0000000 --- a/frontend/app/store/navigationStore — копия 2.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { create } from 'zustand' -import { persist } from 'zustand/middleware' -import type { Zone } from '@/app/types' - -export interface DetectorType { - detector_id: number - name: string - serial_number: string - object: string - status: string - type: string - detector_type: string - location: string - floor: number - checked: boolean - 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 -} - -export interface NavigationStore { - currentObject: { id: string | undefined; title: string | undefined } - navigationHistory: string[] - currentSubmenu: string | null - currentModelPath: string | null - - // Состояния Зон - currentZones: Zone[] - zonesCache: Record - zonesLoading: boolean - zonesError: string | null - - showMonitoring: boolean - showFloorNavigation: boolean - showNotifications: boolean - showListOfDetectors: boolean - showSensors: boolean - showSensorHighlights: boolean - - selectedDetector: DetectorType | null - showDetectorMenu: boolean - selectedNotification: NotificationType | null - showNotificationDetectorInfo: boolean - selectedAlert: AlertType | null - showAlertMenu: boolean - - setCurrentObject: (id: string | undefined, title: string | undefined) => void - clearCurrentObject: () => void - addToHistory: (path: string) => void - goBack: () => string | null - setCurrentModelPath: (path: string) => void - - setCurrentSubmenu: (submenu: string | null) => void - clearSubmenu: () => void - - // Действия с зонами - loadZones: (objectId: string) => Promise - setZones: (zones: Zone[]) => void - clearZones: () => void - - openMonitoring: () => void - closeMonitoring: () => void - openFloorNavigation: () => void - closeFloorNavigation: () => void - openNotifications: () => void - closeNotifications: () => void - openListOfDetectors: () => void - closeListOfDetectors: () => void - openSensors: () => void - closeSensors: () => void - toggleSensorHighlights: () => void - setSensorHighlights: (show: boolean) => void - - closeAllMenus: () => void - clearSelections: () => void - - setSelectedDetector: (detector: DetectorType | null) => void - setShowDetectorMenu: (show: boolean) => void - setSelectedNotification: (notification: NotificationType | null) => void - setShowNotificationDetectorInfo: (show: boolean) => void - setSelectedAlert: (alert: AlertType | null) => void - setShowAlertMenu: (show: boolean) => void - - isOnNavigationPage: () => boolean - getCurrentRoute: () => string | null - getActiveSidebarItem: () => number -} - -const useNavigationStore = create()( - persist( - (set, get) => ({ - currentObject: { - id: undefined, - title: undefined, - }, - navigationHistory: [], - currentSubmenu: null, - currentModelPath: null, - - currentZones: [], - zonesCache: {}, - zonesLoading: false, - zonesError: null, - - showMonitoring: false, - showFloorNavigation: false, - showNotifications: false, - showListOfDetectors: false, - showSensors: false, - showSensorHighlights: true, - - selectedDetector: null, - showDetectorMenu: false, - selectedNotification: null, - showNotificationDetectorInfo: false, - selectedAlert: null, - showAlertMenu: false, - - setCurrentObject: (id: string | undefined, title: string | undefined) => - set({ currentObject: { id, title } }), - - clearCurrentObject: () => - set({ currentObject: { id: undefined, title: undefined } }), - - setCurrentModelPath: (path: string) => set({ currentModelPath: path }), - - addToHistory: (path: string) => { - const { navigationHistory } = get() - const newHistory = [...navigationHistory, path] - if (newHistory.length > 10) { - newHistory.shift() - } - set({ navigationHistory: newHistory }) - }, - - goBack: () => { - const { navigationHistory } = get() - if (navigationHistory.length > 1) { - const newHistory = [...navigationHistory] - newHistory.pop() - const previousPage = newHistory.pop() - set({ navigationHistory: newHistory }) - return previousPage || null - } - return null - }, - - setCurrentSubmenu: (submenu: string | null) => - set({ currentSubmenu: submenu }), - - clearSubmenu: () => - set({ currentSubmenu: null }), - - loadZones: async (objectId: string) => { - const cache = get().zonesCache - const cached = cache[objectId] - const hasCached = Array.isArray(cached) && cached.length > 0 - if (hasCached) { - // Показываем кэшированные зоны сразу, но обновляем в фоне - set({ currentZones: cached, zonesLoading: true, zonesError: null }) - } else { - set({ zonesLoading: true, zonesError: null }) - } - try { - const res = await fetch(`/api/get-zones?objectId=${encodeURIComponent(objectId)}`, { cache: 'no-store' }) - const text = await res.text() - let payload: string | Record - try { payload = JSON.parse(text) } catch { payload = text } - if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error as string || 'Не удалось получить зоны')) - const zones: Zone[] = typeof payload === 'string' ? [] : - Array.isArray(payload?.data) ? payload.data as Zone[] : - (payload?.data && typeof payload.data === 'object' && 'zones' in payload.data ? (payload.data as { zones?: Zone[] }).zones : - payload?.zones ? payload.zones as Zone[] : []) || [] - const normalized = zones.map((z) => ({ - ...z, - image_path: z.image_path ?? null, - })) - set((state) => ({ - currentZones: normalized, - zonesCache: { ...state.zonesCache, [objectId]: normalized }, - zonesLoading: false, - zonesError: null, - })) - } catch (e: unknown) { - set({ zonesLoading: false, zonesError: (e as Error)?.message || 'Ошибка при загрузке зон' }) - } - }, - - setZones: (zones: Zone[]) => set({ currentZones: zones }), - clearZones: () => set({ currentZones: [] }), - - openMonitoring: () => { - set({ - showMonitoring: true, - showFloorNavigation: false, - showNotifications: false, - showListOfDetectors: false, - currentSubmenu: 'monitoring', - showDetectorMenu: false, - selectedDetector: null, - showNotificationDetectorInfo: false, - selectedNotification: null, - zonesError: null // Очищаем ошибку зон при открытии мониторинга - }) - const objId = get().currentObject.id - if (objId) { - // Вызываем загрузку зон сразу, но обновляем в фоне - get().loadZones(objId) - } - }, - - closeMonitoring: () => set({ - showMonitoring: false, - currentSubmenu: null - }), - - openFloorNavigation: () => set({ - showFloorNavigation: true, - showMonitoring: false, - showNotifications: false, - showListOfDetectors: false, - currentSubmenu: 'floors', - showNotificationDetectorInfo: false, - selectedNotification: null - }), - - closeFloorNavigation: () => set({ - showFloorNavigation: false, - showDetectorMenu: false, - selectedDetector: null, - currentSubmenu: null - }), - - openNotifications: () => set({ - showNotifications: true, - showMonitoring: false, - showFloorNavigation: false, - showListOfDetectors: false, - currentSubmenu: 'notifications', - showDetectorMenu: false, - selectedDetector: null - }), - - closeNotifications: () => set({ - showNotifications: false, - showNotificationDetectorInfo: false, - selectedNotification: null, - currentSubmenu: null - }), - - openListOfDetectors: () => set({ - showListOfDetectors: true, - showMonitoring: false, - showFloorNavigation: false, - showNotifications: false, - currentSubmenu: 'detectors', - showDetectorMenu: false, - selectedDetector: null, - showNotificationDetectorInfo: false, - selectedNotification: null - }), - - closeListOfDetectors: () => set({ - showListOfDetectors: false, - showDetectorMenu: false, - selectedDetector: null, - currentSubmenu: null - }), - - openSensors: () => set({ - showSensors: true, - showMonitoring: false, - showFloorNavigation: false, - showNotifications: false, - showListOfDetectors: false, - currentSubmenu: 'sensors', - showDetectorMenu: false, - selectedDetector: null, - showNotificationDetectorInfo: false, - selectedNotification: null - }), - - closeSensors: () => set({ - showSensors: false, - showDetectorMenu: false, - selectedDetector: null, - currentSubmenu: null - }), - - toggleSensorHighlights: () => set((state) => ({ showSensorHighlights: !state.showSensorHighlights })), - setSensorHighlights: (show: boolean) => set({ showSensorHighlights: show }), - - closeAllMenus: () => { - set({ - showMonitoring: false, - showFloorNavigation: false, - showNotifications: false, - showListOfDetectors: false, - showSensors: false, - currentSubmenu: null, - }); - get().clearSelections(); - }, - - clearSelections: () => set({ - selectedDetector: null, - showDetectorMenu: false, - selectedAlert: null, - showAlertMenu: false, - }), - - setSelectedDetector: (detector: DetectorType | null) => set({ selectedDetector: detector }), - setShowDetectorMenu: (show: boolean) => set({ showDetectorMenu: show }), - setSelectedNotification: (notification: NotificationType | null) => set({ selectedNotification: notification }), - setShowNotificationDetectorInfo: (show: boolean) => set({ showNotificationDetectorInfo: show }), - setSelectedAlert: (alert: AlertType | null) => set({ selectedAlert: alert }), - setShowAlertMenu: (show: boolean) => set({ showAlertMenu: show }), - - isOnNavigationPage: () => { - const { navigationHistory } = get() - const currentRoute = navigationHistory[navigationHistory.length - 1] - return currentRoute === '/navigation' - }, - - getCurrentRoute: () => { - const { navigationHistory } = get() - return navigationHistory[navigationHistory.length - 1] || null - }, - - getActiveSidebarItem: () => { - const { showMonitoring, showFloorNavigation, showNotifications, showListOfDetectors, showSensors } = get() - if (showMonitoring) return 3 // Зоны Мониторинга - if (showFloorNavigation) return 4 // Навигация по этажам - if (showNotifications) return 5 // Уведомления - if (showListOfDetectors) return 7 // Список датчиков - if (showSensors) return 8 // Сенсоры - return 2 // Навигация (базовая) - } - }), - { - name: 'navigation-store', - } - ) -) - -export default useNavigationStore - \ No newline at end of file diff --git a/frontend/app/store/navigationStore — копия.ts b/frontend/app/store/navigationStore — копия.ts index 6988e35..a43c9ed 100644 --- a/frontend/app/store/navigationStore — копия.ts +++ b/frontend/app/store/navigationStore — копия.ts @@ -68,6 +68,7 @@ export interface NavigationStore { showNotifications: boolean showListOfDetectors: boolean showSensors: boolean + showSensorHighlights: boolean selectedDetector: DetectorType | null showDetectorMenu: boolean @@ -100,6 +101,8 @@ export interface NavigationStore { closeListOfDetectors: () => void openSensors: () => void closeSensors: () => void + toggleSensorHighlights: () => void + setSensorHighlights: (show: boolean) => void closeAllMenus: () => void clearSelections: () => void @@ -114,6 +117,9 @@ export interface NavigationStore { isOnNavigationPage: () => boolean getCurrentRoute: () => string | null getActiveSidebarItem: () => number + + // Навигация к датчику на 3D-модели + navigateToSensor: (sensorSerialNumber: string, floor: number | null, viewType: 'building' | 'floor') => Promise } const useNavigationStore = create()( @@ -137,6 +143,7 @@ const useNavigationStore = create()( showNotifications: false, showListOfDetectors: false, showSensors: false, + showSensorHighlights: true, selectedDetector: null, showDetectorMenu: false, @@ -316,6 +323,9 @@ const useNavigationStore = create()( currentSubmenu: null }), + toggleSensorHighlights: () => set((state) => ({ showSensorHighlights: !state.showSensorHighlights })), + setSensorHighlights: (show: boolean) => set({ showSensorHighlights: show }), + closeAllMenus: () => { set({ showMonitoring: false, @@ -361,6 +371,105 @@ const useNavigationStore = create()( if (showListOfDetectors) return 7 // Список датчиков if (showSensors) return 8 // Сенсоры 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 } }), { diff --git a/frontend/components/alerts/AlertsList.tsx b/frontend/components/alerts/AlertsList.tsx index 6dc20df..80ca5f5 100644 --- a/frontend/components/alerts/AlertsList.tsx +++ b/frontend/components/alerts/AlertsList.tsx @@ -61,14 +61,14 @@ const AlertsList: React.FC = ({ alerts, onAcknowledgeToggle, in return } - const sensorSerialNumber = await navigateToSensor( + const result = await navigateToSensor( sensorId, alert.floor || null, viewType ) - if (sensorSerialNumber) { - router.push(`/navigation?focusSensorId=${encodeURIComponent(sensorSerialNumber)}`) + if (result) { + router.push(`/navigation?focusSensorId=${encodeURIComponent(result.sensorSerialNumber)}&modelPath=${encodeURIComponent(result.modelPath)}`) } } diff --git a/frontend/components/alerts/AlertsList.tsx — копия 2 b/frontend/components/alerts/AlertsList.tsx — копия 2 index e798040..6dc20df 100644 --- a/frontend/components/alerts/AlertsList.tsx — копия 2 +++ b/frontend/components/alerts/AlertsList.tsx — копия 2 @@ -103,7 +103,6 @@ const AlertsList: React.FC = ({ alerts, onAcknowledgeToggle, in Детектор Статус Сообщение - Местоположение Приоритет Подтверждено Время @@ -133,9 +132,6 @@ const AlertsList: React.FC = ({ alerts, onAcknowledgeToggle, in {item.message} - - {item.location || '-'} - = ({ alerts, onAcknowledgeToggle, in ))} {filteredAlerts.length === 0 && ( - + Записей не найдено diff --git a/frontend/components/dashboard/Dashboard.tsx b/frontend/components/dashboard/Dashboard.tsx index c7e144e..3105470 100644 --- a/frontend/components/dashboard/Dashboard.tsx +++ b/frontend/components/dashboard/Dashboard.tsx @@ -129,15 +129,15 @@ const Dashboard: React.FC = () => { return } - const sensorSerialNumber = await navigateToSensor( + const result = await navigateToSensor( sensorId, alert.floor || null, viewType ) - if (sensorSerialNumber) { - // Переходим на страницу навигации с параметром focusSensorId - router.push(`/navigation?focusSensorId=${encodeURIComponent(sensorSerialNumber)}`) + if (result) { + // Переходим на страницу навигации с параметрами focusSensorId и modelPath + router.push(`/navigation?focusSensorId=${encodeURIComponent(result.sensorSerialNumber)}&modelPath=${encodeURIComponent(result.modelPath)}`) } } diff --git a/frontend/components/dashboard/Dashboard.tsx — копия 2 b/frontend/components/dashboard/Dashboard.tsx — копия 2 index e169baa..c7e144e 100644 --- a/frontend/components/dashboard/Dashboard.tsx — копия 2 +++ b/frontend/components/dashboard/Dashboard.tsx — копия 2 @@ -8,11 +8,11 @@ import useNavigationStore from '../../app/store/navigationStore' import ChartCard from './ChartCard' import AreaChart from './AreaChart' import BarChart from './BarChart' -import { aggregateChartDataByDays } from '../../lib/chartDataAggregator' +import { aggregateChartDataByDays, aggregateAlertsBySeverity } from '../../lib/chartDataAggregator' const Dashboard: React.FC = () => { const router = useRouter() - const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications } = useNavigationStore() + const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications, navigateToSensor } = useNavigationStore() const objectTitle = currentObject?.title const [dashboardAlerts, setDashboardAlerts] = useState([]) @@ -119,6 +119,27 @@ const Dashboard: React.FC = () => { 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) @@ -132,10 +153,10 @@ const Dashboard: React.FC = () => { setSelectedTablePeriod(period) } - // Агрегируем данные графика в зависимости от периода + // Агрегируем данные графика в зависимости от периода с разделением по severity const chartData = useMemo(() => { - return aggregateChartDataByDays(rawChartData, selectedChartPeriod) - }, [rawChartData, selectedChartPeriod]) + return aggregateAlertsBySeverity(dashboardAlerts, selectedChartPeriod) + }, [dashboardAlerts, selectedChartPeriod]) const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 } const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 } @@ -228,7 +249,7 @@ const Dashboard: React.FC = () => { - ({ value: d.value, label: d.label }))} /> + @@ -266,6 +287,7 @@ const Dashboard: React.FC = () => { Серьезность Дата Решен + 3D Вид @@ -287,6 +309,28 @@ const Dashboard: React.FC = () => { ) } + +
+ + +
+ ))} diff --git a/frontend/components/dashboard/Dashboard.tsx — копия 3 b/frontend/components/dashboard/Dashboard.tsx — копия 3 deleted file mode 100644 index 6559533..0000000 --- a/frontend/components/dashboard/Dashboard.tsx — копия 3 +++ /dev/null @@ -1,368 +0,0 @@ -'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 } 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([]) - const [rawChartData, setRawChartData] = useState<{ timestamp: string; value: number }[]>([]) - const [sensorTypes] = useState>([ - { code: '', name: 'Все датчики' }, - { code: 'GA', name: 'Инклинометр' }, - { code: 'PE', name: 'Танзометр' }, - { code: 'GLE', name: 'Гидроуровень' } - ]) - const [selectedSensorType, setSelectedSensorType] = useState('') - const [selectedChartPeriod, setSelectedChartPeriod] = useState('168') - const [selectedTablePeriod, setSelectedTablePeriod] = useState('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) - } - - // Агрегируем данные графика в зависимости от периода - const chartData = useMemo(() => { - return aggregateChartDataByDays(rawChartData, selectedChartPeriod) - }, [rawChartData, selectedChartPeriod]) - - const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 } - const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 } - - return ( -
- -
- -
- -
-
-
- - -
-
- -
-
-

{objectTitle || 'Объект'}

- -
-
- - - - -
- -
- - -
- - - - -
-
-
- - {/* Карты-графики */} -
- - - - - - ({ value: d.value, label: d.label }))} /> - -
-
- - {/* Список детекторов */} -
-
-
-

Тренды

-
- - - - -
-
- - {/* Таблица */} -
-
- - - - - - - - - - - - - {filteredAlerts.map((alert: any) => ( - - - - - - - - - ))} - -
ДетекторСообщениеСерьезностьДатаРешен3D Вид
{alert.name}{alert.message} - - {alert.severity === 'critical' ? 'Критическое' : alert.severity === 'warning' ? 'Предупреждение' : 'Норма'} - - {new Date(alert.created_at).toLocaleString()} - {alert.resolved ? ( - Да - ) : ( - Нет - ) - } - -
- - -
-
-
-
- - {/* Статистика */} -
-
-
{filteredAlerts.length}
-
Всего
-
-
-
{statusCounts.normal}
-
Норма
-
-
-
{statusCounts.warning}
-
Предупреждения
-
-
-
{statusCounts.critical}
-
Критические
-
-
-
-
-
-
-
- ) -} - -export default Dashboard diff --git a/frontend/components/model/Canvas2DPlan.tsx b/frontend/components/model/Canvas2DPlan.tsx new file mode 100644 index 0000000..4b436e5 --- /dev/null +++ b/frontend/components/model/Canvas2DPlan.tsx @@ -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 + onClose: () => void + onSensorClick?: (sensorId: string) => void +} + +interface Sensor2D { + id: string + x: number + y: number + status: string +} + +const Canvas2DPlan: React.FC = ({ + meshes, + sensorStatusMap, + onClose, + onSensorClick, +}) => { + const canvasRef = useRef(null) + const [sensors, setSensors] = useState([]) + const [hoveredSensor, setHoveredSensor] = useState(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) => { + 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) => { + 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) => { + 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) => { + setIsDragging(true) + setDragStart({ x: e.clientX, y: e.clientY }) + } + + const handleMouseUp = () => { + setIsDragging(false) + } + + return ( +
+
+ {/* Заголовок */} +
+

2D План-схема

+ +
+ + {/* Canvas */} +
+ +
+ + {/* Подсказка */} +
+

Колесико мыши - масштаб | Перетаскивание - перемещение | Клик по датчику - подробности

+
+
+
+ ) +} + +export default Canvas2DPlan diff --git a/frontend/components/model/ModelViewer.tsx — копия 3 b/frontend/components/model/ModelViewer.tsx — копия 3 new file mode 100644 index 0000000..2dfa720 --- /dev/null +++ b/frontend/components/model/ModelViewer.tsx — копия 3 @@ -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 +} + +const ModelViewer: React.FC = ({ + modelPath, + onSelectModel, + onModelLoaded, + onError, + focusSensorId, + renderOverlay, + isSensorSelectionEnabled, + onSensorPick, + highlightAllSensors, + sensorStatusMap, + showStats = false, + onToggleStats, +}) => { + const canvasRef = useRef(null) + const engineRef = useRef>(null) + const sceneRef = useRef>(null) + const [isLoading, setIsLoading] = useState(false) + const [loadingProgress, setLoadingProgress] = useState(0) + const [showModel, setShowModel] = useState(false) + const isInitializedRef = useRef(false) + const isDisposedRef = useRef(false) + const importedMeshesRef = useRef([]) + const highlightLayerRef = useRef(null) + const highlightedMeshesRef = useRef([]) + const chosenMeshRef = useRef(null) + const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null) + const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null) + const [modelReady, setModelReady] = useState(false) + const [panActive, setPanActive] = useState(false); + const [webglError, setWebglError] = useState(null) + const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState< + { sensorId: string; left: number; top: number; colorHex: string }[] + >([]) + // NEW: State for tracking hovered sensor in overlay circles + const [hoveredSensorId, setHoveredSensorId] = useState(null) + + const handlePan = () => setPanActive(!panActive); + + useEffect(() => { + const scene = sceneRef.current; + const camera = scene?.activeCamera as ArcRotateCamera; + const canvas = canvasRef.current; + + if (!scene || !camera || !canvas) { + return; + } + + let observer: any = null; + + if (panActive) { + camera.detachControl(); + + observer = scene.onPointerObservable.add((pointerInfo: PointerInfo) => { + const evt = pointerInfo.event; + + if (evt.buttons === 1) { + camera.inertialPanningX -= evt.movementX / camera.panningSensibility; + camera.inertialPanningY += evt.movementY / camera.panningSensibility; + } + else if (evt.buttons === 2) { + camera.inertialAlphaOffset -= evt.movementX / camera.angularSensibilityX; + camera.inertialBetaOffset -= evt.movementY / camera.angularSensibilityY; + } + }, PointerEventTypes.POINTERMOVE); + + } else { + camera.detachControl(); + camera.attachControl(canvas, true); + } + + return () => { + if (observer) { + scene.onPointerObservable.remove(observer); + } + if (!camera.isDisposed() && !camera.inputs.attachedToElement) { + camera.attachControl(canvas, true); + } + }; + }, [panActive, sceneRef, canvasRef]); + + const handleZoomIn = () => { + const camera = sceneRef.current?.activeCamera as ArcRotateCamera + if (camera) { + sceneRef.current?.stopAnimation(camera) + const ease = new CubicEase() + ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT) + + const frameRate = 60 + const durationMs = 300 + const totalFrames = Math.round((durationMs / 1000) * frameRate) + + const currentRadius = camera.radius + const targetRadius = Math.max(camera.lowerRadiusLimit ?? 0.1, currentRadius * 0.8) + + Animation.CreateAndStartAnimation( + 'zoomIn', + camera, + 'radius', + frameRate, + totalFrames, + currentRadius, + targetRadius, + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ) + } + } + const handleZoomOut = () => { + const camera = sceneRef.current?.activeCamera as ArcRotateCamera + if (camera) { + sceneRef.current?.stopAnimation(camera) + const ease = new CubicEase() + ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT) + + const frameRate = 60 + const durationMs = 300 + const totalFrames = Math.round((durationMs / 1000) * frameRate) + + const currentRadius = camera.radius + const targetRadius = Math.min(camera.upperRadiusLimit ?? Infinity, currentRadius * 1.2) + + Animation.CreateAndStartAnimation( + 'zoomOut', + camera, + 'radius', + frameRate, + totalFrames, + currentRadius, + targetRadius, + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ) + } + } + const handleTopView = () => { + const camera = sceneRef.current?.activeCamera as ArcRotateCamera; + if (camera) { + sceneRef.current?.stopAnimation(camera); + const ease = new CubicEase(); + ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT); + + const frameRate = 60; + const durationMs = 500; + const totalFrames = Math.round((durationMs / 1000) * frameRate); + + Animation.CreateAndStartAnimation( + 'topViewAlpha', + camera, + 'alpha', + frameRate, + totalFrames, + camera.alpha, + Math.PI / 2, + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ); + + Animation.CreateAndStartAnimation( + 'topViewBeta', + camera, + 'beta', + frameRate, + totalFrames, + camera.beta, + 0, + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ); + } + }; + + // NEW: Function to handle overlay circle click + const handleOverlayCircleClick = (sensorId: string) => { + console.log('[ModelViewer] Overlay circle clicked:', sensorId) + + // Find the mesh for this sensor + const allMeshes = importedMeshesRef.current || [] + const sensorMeshes = collectSensorMeshes(allMeshes, 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 ( +
+ {!modelPath ? ( +
+
+
+ 3D модель не выбрана +
+
+ Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр +
+
+ Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API +
+
+
+ ) : ( + <> + + {webglError ? ( +
+
+
+ 3D просмотр недоступен +
+
+ {webglError} +
+
+ Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве +
+
+
+ ) : isLoading ? ( +
+ +
+ ) : !modelReady ? ( +
+
+
+ 3D модель не загружена +
+
+ Модель не готова к отображению +
+
+
+ ) : null} + + + + )} + {/* UPDATED: Interactive overlay circles with hover effects */} + {allSensorsOverlayCircles.map(circle => { + const size = 36 + const radius = size / 2 + const fill = hexWithAlpha(circle.colorHex, 0.2) + const isHovered = hoveredSensorId === circle.sensorId + + return ( +
handleOverlayCircleClick(circle.sensorId)} + onMouseEnter={() => setHoveredSensorId(circle.sensorId)} + onMouseLeave={() => setHoveredSensorId(null)} + style={{ + position: 'absolute', + left: circle.left - radius, + top: circle.top - radius, + width: size, + height: size, + borderRadius: '9999px', + border: `2px solid ${circle.colorHex}`, + backgroundColor: fill, + pointerEvents: 'auto', + cursor: 'pointer', + transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)', + transform: isHovered ? 'scale(1.4)' : 'scale(1)', + boxShadow: isHovered + ? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}` + : `0 0 8px ${circle.colorHex}`, + zIndex: isHovered ? 50 : 10, + }} + title={`Датчик: ${circle.sensorId}`} + /> + ) + })} + {renderOverlay && overlayPos && overlayData + ? renderOverlay({ anchor: overlayPos, info: overlayData }) + : null + } +
+ ) +} + +export default React.memo(ModelViewer) diff --git a/frontend/public/icons/Map.svg b/frontend/public/icons/Map.svg new file mode 100644 index 0000000..5759bb8 --- /dev/null +++ b/frontend/public/icons/Map.svg @@ -0,0 +1,5 @@ + + + + +