diff --git a/frontend/app/(protected)/navigation/page.tsx b/frontend/app/(protected)/navigation/page.tsx index 8941094..b4302f1 100644 --- a/frontend/app/(protected)/navigation/page.tsx +++ b/frontend/app/(protected)/navigation/page.tsx @@ -103,7 +103,9 @@ const NavigationPage: React.FC = () => { setSelectedNotification, setShowNotificationDetectorInfo, setSelectedAlert, - setShowAlertMenu + setShowAlertMenu, + showSensorHighlights, + toggleSensorHighlights } = useNavigationStore() const [detectorsData, setDetectorsData] = useState<{ detectors: Record }>({ detectors: {} }) @@ -574,7 +576,7 @@ const NavigationPage: React.FC = () => { onError={handleModelError} activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null} focusSensorId={focusedSensorId} - highlightAllSensors={highlightAllSensors} + highlightAllSensors={showSensorHighlights && highlightAllSensors} sensorStatusMap={sensorStatusMap} isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors} onSensorPick={handleSensorSelection} diff --git a/frontend/app/(protected)/navigation/page.tsx — копия 2 b/frontend/app/(protected)/navigation/page.tsx — копия 2 new file mode 100644 index 0000000..8941094 --- /dev/null +++ b/frontend/app/(protected)/navigation/page.tsx — копия 2 @@ -0,0 +1,613 @@ +'use client' + +import React, { useEffect, useCallback, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import Image from 'next/image' +import Sidebar from '../../../components/ui/Sidebar' +import AnimatedBackground from '../../../components/ui/AnimatedBackground' +import useNavigationStore from '../../store/navigationStore' +import Monitoring from '../../../components/navigation/Monitoring' +import FloorNavigation from '../../../components/navigation/FloorNavigation' +import DetectorMenu from '../../../components/navigation/DetectorMenu' +import ListOfDetectors from '../../../components/navigation/ListOfDetectors' +import Sensors from '../../../components/navigation/Sensors' +import AlertMenu from '../../../components/navigation/AlertMenu' +import Notifications from '../../../components/notifications/Notifications' +import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo' +import dynamic from 'next/dynamic' +import type { ModelViewerProps } from '../../../components/model/ModelViewer' +import * as statusColors from '../../../lib/statusColors' + +const ModelViewer = dynamic(() => import('../../../components/model/ModelViewer'), { + ssr: false, + loading: () => ( +
+
Загрузка 3D-модуля…
+
+ ), + }) + +interface DetectorType { + detector_id: number + name: string + serial_number: string + object: string + status: string + checked: boolean + type: string + detector_type: string + location: string + floor: number + notifications: Array<{ + id: number + type: string + message: string + timestamp: string + acknowledged: boolean + priority: string + }> +} + +interface NotificationType { + id: number + detector_id: number + detector_name: string + type: string + status: string + message: string + timestamp: string + location: string + object: string + acknowledged: boolean + priority: string +} + +interface AlertType { + id: number + detector_id: number + detector_name: string + type: string + status: string + message: string + timestamp: string + location: string + object: string + acknowledged: boolean + priority: string +} + +const NavigationPage: React.FC = () => { + const router = useRouter() + const searchParams = useSearchParams() + const { + currentObject, + setCurrentObject, + showMonitoring, + showFloorNavigation, + showNotifications, + showListOfDetectors, + showSensors, + selectedDetector, + showDetectorMenu, + selectedNotification, + showNotificationDetectorInfo, + selectedAlert, + showAlertMenu, + closeMonitoring, + closeFloorNavigation, + closeNotifications, + closeListOfDetectors, + closeSensors, + setSelectedDetector, + setShowDetectorMenu, + setSelectedNotification, + setShowNotificationDetectorInfo, + setSelectedAlert, + setShowAlertMenu + } = useNavigationStore() + + const [detectorsData, setDetectorsData] = useState<{ detectors: Record }>({ detectors: {} }) + const [detectorsError, setDetectorsError] = useState(null) + const [modelError, setModelError] = useState(null) + const [isModelReady, setIsModelReady] = useState(false) + const [focusedSensorId, setFocusedSensorId] = useState(null) + const [highlightAllSensors, setHighlightAllSensors] = useState(false) + const sensorStatusMap = React.useMemo(() => { + const map: Record = {} + Object.values(detectorsData.detectors).forEach(d => { + if (d.serial_number && d.status) { + map[String(d.serial_number).trim()] = d.status + } + }) + console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors') + console.log('[NavigationPage] Sample sensor IDs in map:', Object.keys(map).slice(0, 5)) + return map + }, [detectorsData]) + + useEffect(() => { + if (selectedDetector === null && selectedAlert === null) { + setFocusedSensorId(null); + } + }, [selectedDetector, selectedAlert]); + + // Управление выделением всех сенсоров при открытии/закрытии меню Sensors + // ИСПРАВЛЕНО: Подсветка датчиков остается включенной всегда, независимо от состояния панели Sensors + useEffect(() => { + console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady) + if (isModelReady) { + // Всегда включаем подсветку всех сенсоров когда модель готова + console.log('[NavigationPage] Setting highlightAllSensors to TRUE (always enabled)') + setHighlightAllSensors(true) + // Сбрасываем фокус только если панель Sensors закрыта + if (!showSensors) { + setFocusedSensorId(null) + } + } + }, [showSensors, isModelReady]) + + // Дополнительный эффект для задержки выделения сенсоров при открытии меню + // ИСПРАВЛЕНО: Задержка применяется только при открытии панели Sensors + useEffect(() => { + if (showSensors && isModelReady) { + const timer = setTimeout(() => { + console.log('[NavigationPage] Delayed highlightAllSensors to TRUE') + setHighlightAllSensors(true) + }, 500) // Задержка 500мс для полной инициализации модели + + return () => clearTimeout(timer) + } + }, [showSensors, isModelReady]) + + const urlObjectId = searchParams.get('objectId') + const urlObjectTitle = searchParams.get('objectTitle') + const urlModelPath = searchParams.get('modelPath') + const objectId = currentObject.id || urlObjectId + const objectTitle = currentObject.title || urlObjectTitle + const [selectedModelPath, setSelectedModelPath] = useState(urlModelPath || '') + + const handleModelLoaded = useCallback(() => { + setIsModelReady(true) + setModelError(null) + }, []) + + const handleModelError = useCallback((error: string) => { + console.error('[NavigationPage] Model loading error:', error) + setModelError(error) + setIsModelReady(false) + }, []) + + useEffect(() => { + if (selectedModelPath) { + setIsModelReady(false); + setModelError(null); + // Сохраняем выбранную модель в URL для восстановления при возврате + const params = new URLSearchParams(searchParams.toString()); + params.set('modelPath', selectedModelPath); + window.history.replaceState(null, '', `?${params.toString()}`); + } + }, [selectedModelPath, searchParams]); + + useEffect(() => { + if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) { + setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined) + } + }, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject]) + + // Восстановление выбранной модели из URL при загрузке страницы + useEffect(() => { + if (urlModelPath && !selectedModelPath) { + setSelectedModelPath(urlModelPath); + } + }, [urlModelPath, selectedModelPath]) + + useEffect(() => { + const loadDetectors = async () => { + try { + setDetectorsError(null) + const res = await fetch('/api/get-detectors', { cache: 'no-store' }) + const text = await res.text() + let payload: any + try { payload = JSON.parse(text) } catch { payload = text } + console.log('[NavigationPage] GET /api/get-detectors', { status: res.status, payload }) + if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов')) + const data = payload?.data ?? payload + const detectors = (data?.detectors ?? {}) as Record + console.log('[NavigationPage] Received detectors count:', Object.keys(detectors).length) + console.log('[NavigationPage] Sample detector keys:', Object.keys(detectors).slice(0, 5)) + setDetectorsData({ detectors }) + } catch (e: any) { + console.error('Ошибка загрузки детекторов:', e) + setDetectorsError(e?.message || 'Ошибка при загрузке детекторов') + } + } + loadDetectors() + }, []) + + const handleBackClick = () => { + router.push('/dashboard') + } + + const handleDetectorMenuClick = (detector: DetectorType) => { + // Для тестов. Выбор детектора. + console.log('[NavigationPage] Selected detector click:', { + detector_id: detector.detector_id, + name: detector.name, + serial_number: detector.serial_number, + }) + + // Проверяем, что детектор имеет необходимые данные + if (!detector || !detector.detector_id || !detector.serial_number) { + console.warn('[NavigationPage] Invalid detector data, skipping menu display:', detector) + return + } + + if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) { + closeDetectorMenu() + } else { + setSelectedDetector(detector) + setShowDetectorMenu(true) + setFocusedSensorId(detector.serial_number) + setShowAlertMenu(false) + setSelectedAlert(null) + // При открытии меню детектора - сбрасываем множественное выделение + setHighlightAllSensors(false) + } + } + + const closeDetectorMenu = () => { + setShowDetectorMenu(false) + setSelectedDetector(null) + setFocusedSensorId(null) + setSelectedAlert(null) + // При закрытии меню детектора - выделяем все сенсоры снова + setHighlightAllSensors(true) + } + + const handleNotificationClick = (notification: NotificationType) => { + if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) { + setShowNotificationDetectorInfo(false) + setSelectedNotification(null) + } else { + setSelectedNotification(notification) + setShowNotificationDetectorInfo(true) + } + } + + const closeNotificationDetectorInfo = () => { + setShowNotificationDetectorInfo(false) + setSelectedNotification(null) + } + + const closeAlertMenu = () => { + setShowAlertMenu(false) + setSelectedAlert(null) + setFocusedSensorId(null) + setSelectedDetector(null) + // При закрытии меню алерта - выделяем все сенсоры снова + setHighlightAllSensors(true) + } + + const handleAlertClick = (alert: AlertType) => { + console.log('[NavigationPage] Alert clicked, focusing on detector in 3D scene:', alert) + + const detector = Object.values(detectorsData.detectors).find( + d => d.detector_id === alert.detector_id + ) + + if (detector) { + if (selectedAlert?.id === alert.id && showAlertMenu) { + closeAlertMenu() + } else { + setSelectedAlert(alert) + setShowAlertMenu(true) + setFocusedSensorId(detector.serial_number) + setShowDetectorMenu(false) + setSelectedDetector(null) + // При открытии меню алерта - сбрасываем множественное выделение + setHighlightAllSensors(false) + console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name) + } + } else { + console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id) + } + } + + const handleSensorSelection = (serialNumber: string | null) => { + if (serialNumber === null) { + setFocusedSensorId(null); + closeDetectorMenu(); + closeAlertMenu(); + // If we're in Sensors menu and no sensor is selected, highlight all sensors + if (showSensors) { + setHighlightAllSensors(true); + } + return; + } + + if (focusedSensorId === serialNumber) { + setFocusedSensorId(null); + closeDetectorMenu(); + closeAlertMenu(); + // If we're in Sensors menu and deselected the current sensor, highlight all sensors + if (showSensors) { + setHighlightAllSensors(true); + } + return; + } + + // При выборе конкретного сенсора - сбрасываем множественное выделение + setHighlightAllSensors(false) + + const detector = Object.values(detectorsData?.detectors || {}).find( + (d) => d.serial_number === serialNumber + ); + + if (detector) { + if (showFloorNavigation || showListOfDetectors) { + handleDetectorMenuClick(detector); + } else if (detector.notifications && detector.notifications.length > 0) { + const sortedNotifications = [...detector.notifications].sort((a, b) => { + const priorityOrder: { [key: string]: number } = { critical: 0, warning: 1, info: 2 }; + return priorityOrder[a.priority.toLowerCase()] - priorityOrder[b.priority.toLowerCase()]; + }); + const notification = sortedNotifications[0]; + const alert: AlertType = { + ...notification, + detector_id: detector.detector_id, + detector_name: detector.name, + location: detector.location, + object: detector.object, + status: detector.status, + type: notification.type || 'info', + }; + handleAlertClick(alert); + } else { + handleDetectorMenuClick(detector); + } + } else { + setFocusedSensorId(null); + closeDetectorMenu(); + closeAlertMenu(); + // If we're in Sensors menu and no valid detector found, highlight all sensors + if (showSensors) { + setHighlightAllSensors(true); + } + } + }; + + const getStatusText = (status: string) => { + const s = (status || '').toLowerCase() + switch (s) { + case statusColors.STATUS_COLOR_CRITICAL: + case 'critical': + return 'Критический' + case statusColors.STATUS_COLOR_WARNING: + case 'warning': + return 'Предупреждение' + case statusColors.STATUS_COLOR_NORMAL: + case 'normal': + return 'Норма' + default: + return 'Неизвестно' + } + } + + return ( +
+ +
+ +
+ +
+ + {showMonitoring && ( +
+
+ { + console.log('[NavigationPage] Model selected:', path); + setSelectedModelPath(path) + setModelError(null) + setIsModelReady(false) + }} + /> +
+
+ )} + + {showFloorNavigation && ( +
+
+ +
+
+ )} + + {showNotifications && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showListOfDetectors && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showSensors && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showNotifications && showNotificationDetectorInfo && selectedNotification && (() => { + const detectorData = Object.values(detectorsData.detectors).find( + detector => detector.detector_id === selectedNotification.detector_id + ); + return detectorData ? ( +
+
+ +
+
+ ) : null; + })()} + + {showFloorNavigation && showDetectorMenu && selectedDetector && ( + null + )} + +
+
+
+ + +
+
+
+ +
+
+ {modelError ? ( + <> + {console.log('[NavigationPage] Rendering error message, modelError:', modelError)} +
+
+
+ Ошибка загрузки 3D модели +
+
+ {modelError} +
+
+ Используйте навигацию по этажам для просмотра детекторов +
+
+
+ + ) : !selectedModelPath ? ( +
+
+ AerBIM HT Monitor +
+ Выберите модель для отображения +
+
+
+ ) : ( + { + console.log('[NavigationPage] Model selected:', path); + setSelectedModelPath(path) + setModelError(null) + setIsModelReady(false) + }} + onModelLoaded={handleModelLoaded} + onError={handleModelError} + activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null} + focusSensorId={focusedSensorId} + highlightAllSensors={highlightAllSensors} + sensorStatusMap={sensorStatusMap} + isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors} + onSensorPick={handleSensorSelection} + renderOverlay={({ anchor }) => ( + <> + {selectedAlert && showAlertMenu && anchor ? ( + + ) : selectedDetector && showDetectorMenu && anchor ? ( + + ) : null} + + )} + /> + )} +
+
+
+
+ ) +} + +export default NavigationPage diff --git a/frontend/app/store/navigationStore.ts b/frontend/app/store/navigationStore.ts index 6988e35..49ec904 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 @@ -137,6 +140,7 @@ const useNavigationStore = create()( showNotifications: false, showListOfDetectors: false, showSensors: false, + showSensorHighlights: true, selectedDetector: null, showDetectorMenu: false, @@ -316,6 +320,9 @@ const useNavigationStore = create()( currentSubmenu: null }), + toggleSensorHighlights: () => set((state) => ({ showSensorHighlights: !state.showSensorHighlights })), + setSensorHighlights: (show: boolean) => set({ showSensorHighlights: show }), + closeAllMenus: () => { set({ showMonitoring: false, diff --git a/frontend/app/store/navigationStore — копия.ts b/frontend/app/store/navigationStore — копия.ts new file mode 100644 index 0000000..6988e35 --- /dev/null +++ b/frontend/app/store/navigationStore — копия.ts @@ -0,0 +1,373 @@ +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 + + 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 + + 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, + + 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 + }), + + 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/components/model/ModelViewer.tsx b/frontend/components/model/ModelViewer.tsx index 2ee6469..2ba0366 100644 --- a/frontend/components/model/ModelViewer.tsx +++ b/frontend/components/model/ModelViewer.tsx @@ -25,6 +25,7 @@ import '@babylonjs/loaders' import SceneToolbar from './SceneToolbar'; import LoadingSpinner from '../ui/LoadingSpinner' +import useNavigationStore from '@/app/store/navigationStore' import { getSensorIdFromMesh, collectSensorMeshes, @@ -862,6 +863,8 @@ const ModelViewer: React.FC = ({ onPan={handlePan} onSelectModel={onSelectModel} panActive={panActive} + onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights} + sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights} /> )} diff --git a/frontend/components/model/ModelViewer.tsx — копия 2 b/frontend/components/model/ModelViewer.tsx — копия 2 new file mode 100644 index 0000000..2ee6469 --- /dev/null +++ b/frontend/components/model/ModelViewer.tsx — копия 2 @@ -0,0 +1,911 @@ +'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, +} from '@babylonjs/core' +import '@babylonjs/loaders' + +import SceneToolbar from './SceneToolbar'; +import LoadingSpinner from '../ui/LoadingSpinner' +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, +}) => { + const canvasRef = useRef(null) + const engineRef = useRef>(null) + const sceneRef = useRef>(null) + const [isLoading, setIsLoading] = useState(false) + const [loadingProgress, setLoadingProgress] = useState(0) + const [showModel, setShowModel] = useState(false) + const isInitializedRef = useRef(false) + const isDisposedRef = useRef(false) + const importedMeshesRef = useRef([]) + const highlightLayerRef = useRef(null) + const highlightedMeshesRef = useRef([]) + const chosenMeshRef = useRef(null) + const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null) + const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null) + const [modelReady, setModelReady] = useState(false) + const [panActive, setPanActive] = useState(false); + const [webglError, setWebglError] = useState(null) + const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState< + { sensorId: string; left: number; top: number; colorHex: string }[] + >([]) + // NEW: State for tracking hovered sensor in overlay circles + const [hoveredSensorId, setHoveredSensorId] = useState(null) + + const handlePan = () => setPanActive(!panActive); + + useEffect(() => { + const scene = sceneRef.current; + const camera = scene?.activeCamera as ArcRotateCamera; + const canvas = canvasRef.current; + + if (!scene || !camera || !canvas) { + return; + } + + let observer: any = null; + + if (panActive) { + camera.detachControl(); + + observer = scene.onPointerObservable.add((pointerInfo: PointerInfo) => { + const evt = pointerInfo.event; + + if (evt.buttons === 1) { + camera.inertialPanningX -= evt.movementX / camera.panningSensibility; + camera.inertialPanningY += evt.movementY / camera.panningSensibility; + } + else if (evt.buttons === 2) { + camera.inertialAlphaOffset -= evt.movementX / camera.angularSensibilityX; + camera.inertialBetaOffset -= evt.movementY / camera.angularSensibilityY; + } + }, PointerEventTypes.POINTERMOVE); + + } else { + camera.detachControl(); + camera.attachControl(canvas, true); + } + + return () => { + if (observer) { + scene.onPointerObservable.remove(observer); + } + if (!camera.isDisposed() && !camera.inputs.attachedToElement) { + camera.attachControl(canvas, true); + } + }; + }, [panActive, sceneRef, canvasRef]); + + const handleZoomIn = () => { + const camera = sceneRef.current?.activeCamera as ArcRotateCamera + if (camera) { + sceneRef.current?.stopAnimation(camera) + const ease = new CubicEase() + ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT) + + const frameRate = 60 + const durationMs = 300 + const totalFrames = Math.round((durationMs / 1000) * frameRate) + + const currentRadius = camera.radius + const targetRadius = Math.max(camera.lowerRadiusLimit ?? 0.1, currentRadius * 0.8) + + Animation.CreateAndStartAnimation( + 'zoomIn', + camera, + 'radius', + frameRate, + totalFrames, + currentRadius, + targetRadius, + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ) + } + } + const handleZoomOut = () => { + const camera = sceneRef.current?.activeCamera as ArcRotateCamera + if (camera) { + sceneRef.current?.stopAnimation(camera) + const ease = new CubicEase() + ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT) + + const frameRate = 60 + const durationMs = 300 + const totalFrames = Math.round((durationMs / 1000) * frameRate) + + const currentRadius = camera.radius + const targetRadius = Math.min(camera.upperRadiusLimit ?? Infinity, currentRadius * 1.2) + + Animation.CreateAndStartAnimation( + 'zoomOut', + camera, + 'radius', + frameRate, + totalFrames, + currentRadius, + targetRadius, + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ) + } + } + const handleTopView = () => { + const camera = sceneRef.current?.activeCamera as ArcRotateCamera; + if (camera) { + sceneRef.current?.stopAnimation(camera); + const ease = new CubicEase(); + ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT); + + const frameRate = 60; + const durationMs = 500; + const totalFrames = Math.round((durationMs / 1000) * frameRate); + + Animation.CreateAndStartAnimation( + 'topViewAlpha', + camera, + 'alpha', + frameRate, + totalFrames, + camera.alpha, + Math.PI / 2, + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ); + + Animation.CreateAndStartAnimation( + 'topViewBeta', + camera, + 'beta', + frameRate, + totalFrames, + camera.beta, + 0, + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ); + } + }; + + // NEW: Function to handle overlay circle click + const handleOverlayCircleClick = (sensorId: string) => { + console.log('[ModelViewer] Overlay circle clicked:', sensorId) + + // Find the mesh for this sensor + const allMeshes = importedMeshesRef.current || [] + const sensorMeshes = collectSensorMeshes(allMeshes) + const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId) + + if (!targetMesh) { + console.warn(`[ModelViewer] Mesh not found for sensor: ${sensorId}`) + return + } + + const scene = sceneRef.current + const camera = scene?.activeCamera as ArcRotateCamera + if (!scene || !camera) return + + // Calculate bounding box of the sensor mesh + const bbox = (typeof targetMesh.getHierarchyBoundingVectors === 'function') + ? targetMesh.getHierarchyBoundingVectors() + : { + min: targetMesh.getBoundingInfo().boundingBox.minimumWorld, + max: targetMesh.getBoundingInfo().boundingBox.maximumWorld + } + + const center = bbox.min.add(bbox.max).scale(0.5) + const size = bbox.max.subtract(bbox.min) + const maxDimension = Math.max(size.x, size.y, size.z) + + // Calculate optimal camera distance + const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) + + // Stop any current animations + scene.stopAnimation(camera) + + // Setup easing + const ease = new CubicEase() + ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) + + const frameRate = 60 + const durationMs = 600 // 0.6 seconds for smooth animation + const totalFrames = Math.round((durationMs / 1000) * frameRate) + + // Animate camera target position + Animation.CreateAndStartAnimation( + 'camTarget', + camera, + 'target', + frameRate, + totalFrames, + camera.target.clone(), + center.clone(), + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ) + + // Animate camera radius (zoom) + Animation.CreateAndStartAnimation( + 'camRadius', + camera, + 'radius', + frameRate, + totalFrames, + camera.radius, + targetRadius, + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ) + + // Call callback to display tooltip + onSensorPick?.(sensorId) + + console.log('[ModelViewer] Camera animation started for sensor:', sensorId) + } + + useEffect(() => { + isDisposedRef.current = false + isInitializedRef.current = false + return () => { + isDisposedRef.current = true + } + }, []) + + useEffect(() => { + if (!canvasRef.current || isInitializedRef.current) return + + const canvas = canvasRef.current + setWebglError(null) + + let hasWebGL = false + try { + const testCanvas = document.createElement('canvas') + const gl = + testCanvas.getContext('webgl2') || + testCanvas.getContext('webgl') || + testCanvas.getContext('experimental-webgl') + hasWebGL = !!gl + } catch { + hasWebGL = false + } + + if (!hasWebGL) { + const message = 'WebGL не поддерживается в текущем окружении' + setWebglError(message) + onError?.(message) + setIsLoading(false) + setModelReady(false) + return + } + + let engine: Engine + try { + engine = new Engine(canvas, true, { stencil: true }) + } 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) + + const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene) + camera.attachControl(canvas, true) + camera.lowerRadiusLimit = 2 + camera.upperRadiusLimit = 200 + camera.wheelDeltaPercentage = 0.01 + camera.panningSensibility = 50 + camera.angularSensibilityX = 1000 + camera.angularSensibilityY = 1000 + + const ambientLight = new HemisphericLight('ambientLight', new Vector3(0, 1, 0), scene) + ambientLight.intensity = 0.4 + ambientLight.diffuse = new Color3(0.7, 0.7, 0.8) + ambientLight.specular = new Color3(0.2, 0.2, 0.3) + ambientLight.groundColor = new Color3(0.3, 0.3, 0.4) + + const keyLight = new HemisphericLight('keyLight', new Vector3(1, 1, 0), scene) + keyLight.intensity = 0.6 + keyLight.diffuse = new Color3(1, 1, 0.9) + keyLight.specular = new Color3(1, 1, 0.9) + + const fillLight = new HemisphericLight('fillLight', new Vector3(-1, 0.5, -1), scene) + fillLight.intensity = 0.3 + fillLight.diffuse = new Color3(0.8, 0.8, 1) + + const hl = new HighlightLayer('highlight-layer', scene, { + mainTextureRatio: 1, + blurTextureSizeRatio: 1, + }) + hl.innerGlow = false + hl.outerGlow = true + hl.blurHorizontalSize = 2 + hl.blurVerticalSize = 2 + highlightLayerRef.current = hl + + const handleResize = () => { + if (!isDisposedRef.current) { + engine.resize() + } + } + window.addEventListener('resize', handleResize) + + isInitializedRef.current = true + + return () => { + isDisposedRef.current = true + isInitializedRef.current = false + window.removeEventListener('resize', handleResize) + + highlightLayerRef.current?.dispose() + highlightLayerRef.current = null + if (engineRef.current) { + engineRef.current.dispose() + engineRef.current = null + } + sceneRef.current = null + } + }, [onError]) + + useEffect(() => { + if (!modelPath || !sceneRef.current || !engineRef.current) return + + const scene = sceneRef.current + + setIsLoading(true) + setLoadingProgress(0) + setShowModel(false) + setModelReady(false) + + const loadModel = async () => { + try { + console.log('[ModelViewer] Starting to load model:', modelPath) + + // UI элемент загрузчика (есть эффект замедленности) + const progressInterval = setInterval(() => { + setLoadingProgress(prev => { + if (prev >= 90) { + clearInterval(progressInterval) + return 90 + } + return prev + Math.random() * 15 + }) + }, 100) + + // Use the correct ImportMeshAsync signature: (url, scene, onProgress) + const result = await ImportMeshAsync(modelPath, scene, (evt) => { + if (evt.lengthComputable) { + const progress = (evt.loaded / evt.total) * 100 + setLoadingProgress(progress) + console.log('[ModelViewer] Loading progress:', progress) + } + }) + + clearInterval(progressInterval) + + if (isDisposedRef.current) { + console.log('[ModelViewer] Component disposed during load') + return + } + + console.log('[ModelViewer] Model loaded successfully:', { + meshesCount: result.meshes.length, + particleSystemsCount: result.particleSystems.length, + skeletonsCount: result.skeletons.length, + animationGroupsCount: result.animationGroups.length + }) + + importedMeshesRef.current = result.meshes + + if (result.meshes.length > 0) { + const boundingBox = result.meshes[0].getHierarchyBoundingVectors() + + onModelLoaded?.({ + meshes: result.meshes, + boundingBox: { + min: { x: boundingBox.min.x, y: boundingBox.min.y, z: boundingBox.min.z }, + max: { x: boundingBox.max.x, y: boundingBox.max.y, z: boundingBox.max.z }, + }, + }) + + // Автоматическое кадрирование камеры для отображения всей модели + const camera = scene.activeCamera as ArcRotateCamera + if (camera) { + const center = boundingBox.min.add(boundingBox.max).scale(0.5) + const size = boundingBox.max.subtract(boundingBox.min) + const maxDimension = Math.max(size.x, size.y, size.z) + + // Устанавливаем оптимальное расстояние камеры + const targetRadius = maxDimension * 2.5 // Множитель для комфортного отступа + + // Плавная анимация камеры к центру модели + scene.stopAnimation(camera) + + const ease = new CubicEase() + ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) + + const frameRate = 60 + const durationMs = 800 // 0.8 секунды + const totalFrames = Math.round((durationMs / 1000) * frameRate) + + // Анимация позиции камеры + Animation.CreateAndStartAnimation( + 'frameCameraTarget', + camera, + 'target', + frameRate, + totalFrames, + camera.target.clone(), + center.clone(), + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ) + + // Анимация зума + Animation.CreateAndStartAnimation( + 'frameCameraRadius', + camera, + 'radius', + frameRate, + totalFrames, + camera.radius, + targetRadius, + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ) + + console.log('[ModelViewer] Camera framed to model:', { center, targetRadius, maxDimension }) + } + } + + setLoadingProgress(100) + setShowModel(true) + setModelReady(true) + setIsLoading(false) + } catch (error) { + if (isDisposedRef.current) return + const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка' + console.error('[ModelViewer] Error loading model:', errorMessage) + const message = `Ошибка при загрузке модели: ${errorMessage}` + onError?.(message) + setIsLoading(false) + setModelReady(false) + } + } + + loadModel() + }, [modelPath, onModelLoaded, onError]) + + useEffect(() => { + if (!highlightAllSensors || focusSensorId || !modelReady) { + setAllSensorsOverlayCircles([]) + return + } + + const scene = sceneRef.current + const engine = engineRef.current + if (!scene || !engine) { + setAllSensorsOverlayCircles([]) + return + } + + const allMeshes = importedMeshesRef.current || [] + const sensorMeshes = collectSensorMeshes(allMeshes) + if (sensorMeshes.length === 0) { + setAllSensorsOverlayCircles([]) + return + } + + const engineTyped = engine as Engine + const updateCircles = () => { + const circles = computeSensorOverlayCircles({ + scene, + engine: engineTyped, + meshes: sensorMeshes, + sensorStatusMap, + }) + setAllSensorsOverlayCircles(circles) + } + + updateCircles() + const observer = scene.onBeforeRenderObservable.add(updateCircles) + return () => { + scene.onBeforeRenderObservable.remove(observer) + setAllSensorsOverlayCircles([]) + } + }, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap]) + + useEffect(() => { + if (!highlightAllSensors || focusSensorId || !modelReady) { + return + } + + const scene = sceneRef.current + if (!scene) return + + const allMeshes = importedMeshesRef.current || [] + if (allMeshes.length === 0) { + return + } + + const sensorMeshes = collectSensorMeshes(allMeshes) + + console.log('[ModelViewer] Total meshes in model:', allMeshes.length) + console.log('[ModelViewer] Sensor meshes found:', sensorMeshes.length) + + // Log first 5 sensor IDs found in meshes + const sensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean).slice(0, 5) + console.log('[ModelViewer] Sample sensor IDs from meshes:', sensorIds) + + if (sensorMeshes.length === 0) { + console.warn('[ModelViewer] No sensor meshes found in 3D model!') + return + } + + applyHighlightToMeshes( + highlightLayerRef.current, + highlightedMeshesRef, + sensorMeshes, + mesh => { + const sid = getSensorIdFromMesh(mesh) + const status = sid ? sensorStatusMap?.[sid] : undefined + return statusToColor3(status ?? null) + }, + ) + }, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap]) + + useEffect(() => { + if (!focusSensorId || !modelReady) { + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] + highlightLayerRef.current?.removeAllMeshes() + chosenMeshRef.current = null + setOverlayPos(null) + setOverlayData(null) + setAllSensorsOverlayCircles([]) + return + } + + const sensorId = (focusSensorId ?? '').trim() + if (!sensorId) { + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] + highlightLayerRef.current?.removeAllMeshes() + chosenMeshRef.current = null + setOverlayPos(null) + setOverlayData(null) + return + } + + const allMeshes = importedMeshesRef.current || [] + + if (allMeshes.length === 0) { + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] + highlightLayerRef.current?.removeAllMeshes() + chosenMeshRef.current = null + setOverlayPos(null) + setOverlayData(null) + return + } + + const sensorMeshes = collectSensorMeshes(allMeshes) + const allSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)) + const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId) + + console.log('[ModelViewer] Sensor focus', { + requested: sensorId, + totalImportedMeshes: allMeshes.length, + totalSensorMeshes: sensorMeshes.length, + allSensorIds: allSensorIds, + chosen: chosen ? { id: chosen.id, name: chosen.name, uniqueId: chosen.uniqueId, parent: chosen.parent?.name } : null, + source: 'result.meshes', + }) + + const scene = sceneRef.current! + + if (chosen) { + try { + const camera = scene.activeCamera as ArcRotateCamera + const bbox = (typeof chosen.getHierarchyBoundingVectors === 'function') + ? chosen.getHierarchyBoundingVectors() + : { min: chosen.getBoundingInfo().boundingBox.minimumWorld, max: chosen.getBoundingInfo().boundingBox.maximumWorld } + const center = bbox.min.add(bbox.max).scale(0.5) + const size = bbox.max.subtract(bbox.min) + const maxDimension = Math.max(size.x, size.y, size.z) + const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) + + scene.stopAnimation(camera) + + const ease = new CubicEase() + ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) + const frameRate = 60 + const durationMs = 600 + 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) + + applyHighlightToMeshes( + highlightLayerRef.current, + highlightedMeshesRef, + [chosen], + mesh => { + const sid = getSensorIdFromMesh(mesh) + const status = sid ? sensorStatusMap?.[sid] : undefined + return statusToColor3(status ?? null) + }, + ) + chosenMeshRef.current = chosen + setOverlayData({ name: chosen.name, sensorId }) + } catch { + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] + highlightLayerRef.current?.removeAllMeshes() + chosenMeshRef.current = null + setOverlayPos(null) + setOverlayData(null) + } + } else { + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] + highlightLayerRef.current?.removeAllMeshes() + chosenMeshRef.current = null + setOverlayPos(null) + setOverlayData(null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusSensorId, modelReady, highlightAllSensors]) + + useEffect(() => { + const scene = sceneRef.current + if (!scene || !modelReady || !isSensorSelectionEnabled) return + + const pickObserver = scene.onPointerObservable.add((pointerInfo: PointerInfo) => { + if (pointerInfo.type !== PointerEventTypes.POINTERPICK) return + const pick = pointerInfo.pickInfo + if (!pick || !pick.hit) { + onSensorPick?.(null) + return + } + + const pickedMesh = pick.pickedMesh + const sensorId = getSensorIdFromMesh(pickedMesh) + + if (sensorId) { + onSensorPick?.(sensorId) + } else { + onSensorPick?.(null) + } + }) + + return () => { + scene.onPointerObservable.remove(pickObserver) + } + }, [modelReady, isSensorSelectionEnabled, onSensorPick]) + + const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => { + if (!sceneRef.current || !mesh) return null + const scene = sceneRef.current + try { + const bbox = (typeof mesh.getHierarchyBoundingVectors === 'function') + ? mesh.getHierarchyBoundingVectors() + : { min: mesh.getBoundingInfo().boundingBox.minimumWorld, max: mesh.getBoundingInfo().boundingBox.maximumWorld } + const center = bbox.min.add(bbox.max).scale(0.5) + + const viewport = scene.activeCamera?.viewport.toGlobal(engineRef.current!.getRenderWidth(), engineRef.current!.getRenderHeight()) + if (!viewport) return null + + const projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport) + if (!projected) return null + + return { left: projected.x, top: projected.y } + } catch (error) { + console.error('[ModelViewer] Error computing overlay position:', error) + return null + } + }, []) + + useEffect(() => { + if (!chosenMeshRef.current || !overlayData) return + const pos = computeOverlayPosition(chosenMeshRef.current) + setOverlayPos(pos) + }, [overlayData, computeOverlayPosition]) + + useEffect(() => { + if (!sceneRef.current || !chosenMeshRef.current || !overlayData) return + const scene = sceneRef.current + + const updateOverlayPosition = () => { + const pos = computeOverlayPosition(chosenMeshRef.current) + setOverlayPos(pos) + } + scene.registerBeforeRender(updateOverlayPosition) + return () => scene.unregisterBeforeRender(updateOverlayPosition) + }, [overlayData, computeOverlayPosition]) + + return ( +
+ {!modelPath ? ( +
+
+
+ 3D модель не выбрана +
+
+ Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр +
+
+ Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API +
+
+
+ ) : ( + <> + + {webglError ? ( +
+
+
+ 3D просмотр недоступен +
+
+ {webglError} +
+
+ Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве +
+
+
+ ) : isLoading ? ( +
+ +
+ ) : !modelReady ? ( +
+
+
+ 3D модель не загружена +
+
+ Модель не готова к отображению +
+
+
+ ) : null} + + + )} + {/* UPDATED: Interactive overlay circles with hover effects */} + {allSensorsOverlayCircles.map(circle => { + const size = 36 + const radius = size / 2 + const fill = hexWithAlpha(circle.colorHex, 0.2) + const isHovered = hoveredSensorId === circle.sensorId + + return ( +
handleOverlayCircleClick(circle.sensorId)} + onMouseEnter={() => setHoveredSensorId(circle.sensorId)} + onMouseLeave={() => setHoveredSensorId(null)} + style={{ + position: 'absolute', + left: circle.left - radius, + top: circle.top - radius, + width: size, + height: size, + borderRadius: '9999px', + border: `2px solid ${circle.colorHex}`, + backgroundColor: fill, + pointerEvents: 'auto', + cursor: 'pointer', + transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)', + transform: isHovered ? 'scale(1.4)' : 'scale(1)', + boxShadow: isHovered + ? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}` + : `0 0 8px ${circle.colorHex}`, + zIndex: isHovered ? 50 : 10, + }} + title={`Датчик: ${circle.sensorId}`} + /> + ) + })} + {renderOverlay && overlayPos && overlayData + ? renderOverlay({ anchor: overlayPos, info: overlayData }) + : null + } +
+ ) +} + +export default ModelViewer diff --git a/frontend/components/model/SceneToolbar.tsx b/frontend/components/model/SceneToolbar.tsx index bd11148..3be2125 100644 --- a/frontend/components/model/SceneToolbar.tsx +++ b/frontend/components/model/SceneToolbar.tsx @@ -21,6 +21,8 @@ interface SceneToolbarProps { onSelectModel?: (modelPath: string) => void; panActive?: boolean; navMenuActive?: boolean; + onToggleSensorHighlights?: () => void; + sensorHighlightsActive?: boolean; } const SceneToolbar: React.FC = ({ @@ -31,6 +33,8 @@ const SceneToolbar: React.FC = ({ onSelectModel, panActive = false, navMenuActive = false, + onToggleSensorHighlights, + sensorHighlightsActive = true, }) => { const [isZoomOpen, setIsZoomOpen] = useState(false); const { showMonitoring, openMonitoring, closeMonitoring, currentZones, loadZones, currentObject } = useNavigationStore(); @@ -90,41 +94,47 @@ const SceneToolbar: React.FC = ({ const defaultButtons: ToolbarButton[] = [ { icon: '/icons/Zoom.png', - label: 'Zoom', + label: 'Масштаб', onClick: () => setIsZoomOpen(!isZoomOpen), active: isZoomOpen, children: [ { icon: '/icons/plus.svg', - label: 'Zoom In', + label: 'Приблизить', onClick: onZoomIn || (() => {}), }, { icon: '/icons/minus.svg', - label: 'Zoom Out', + label: 'Отдалить', onClick: onZoomOut || (() => {}), }, ] }, { icon: '/icons/Video.png', - label: "Top View", + label: 'Вид сверху', onClick: onTopView || (() => console.log('Top View')), }, { icon: '/icons/Pointer.png', - label: 'Pan', + label: 'Панорамирование', onClick: onPan || (() => console.log('Pan')), active: panActive, }, + { + icon: '/icons/Eye.png', + label: 'Подсветка датчиков', + onClick: onToggleSensorHighlights || (() => console.log('Toggle Sensor Highlights')), + active: sensorHighlightsActive, + }, { icon: '/icons/Warehouse.png', - label: 'Home', + label: 'Домой', onClick: handleHomeClick, }, { icon: '/icons/Layers.png', - label: 'Levels', + label: 'Уровни', onClick: handleToggleNavMenu, active: navMenuActive, }, diff --git a/frontend/components/model/SceneToolbar.tsx — копия b/frontend/components/model/SceneToolbar.tsx — копия new file mode 100644 index 0000000..bd11148 --- /dev/null +++ b/frontend/components/model/SceneToolbar.tsx — копия @@ -0,0 +1,207 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import useNavigationStore from '@/app/store/navigationStore'; +import type { Zone } from '@/app/types'; + +interface ToolbarButton { + icon: string; + label: string; + onClick: () => void; + onMouseDown?: () => void; + onMouseUp?: () => void; + active?: boolean; + children?: ToolbarButton[]; +} + +interface SceneToolbarProps { + onZoomIn?: () => void; + onZoomOut?: () => void; + onTopView?: () => void; + onPan?: () => void; + onSelectModel?: (modelPath: string) => void; + panActive?: boolean; + navMenuActive?: boolean; +} + +const SceneToolbar: React.FC = ({ + onZoomIn, + onZoomOut, + onTopView, + onPan, + onSelectModel, + panActive = false, + navMenuActive = false, +}) => { + const [isZoomOpen, setIsZoomOpen] = useState(false); + const { showMonitoring, openMonitoring, closeMonitoring, currentZones, loadZones, currentObject } = useNavigationStore(); + + const handleToggleNavMenu = () => { + if (showMonitoring) { + closeMonitoring(); + } else { + openMonitoring(); + } + }; + + const handleHomeClick = async () => { + if (!onSelectModel) return; + + try { + let zones: Zone[] = Array.isArray(currentZones) ? currentZones : []; + + // Если зоны ещё не загружены, откройте Monitoring и загрузите зоны для текущего объекта + if ((!zones || zones.length === 0) && currentObject?.id) { + if (!showMonitoring) { + openMonitoring(); + } + await loadZones(currentObject.id); + zones = useNavigationStore.getState().currentZones || []; + } + + if (!Array.isArray(zones) || zones.length === 0) { + console.warn('No zones available to select a model from.'); + return; + } + + const sorted = zones.slice().sort((a: Zone, b: Zone) => { + const oa = typeof a.order === 'number' ? a.order : 0; + const ob = typeof b.order === 'number' ? b.order : 0; + if (oa !== ob) return oa - ob; + return (a.name || '').localeCompare(b.name || ''); + }); + + const top = sorted[0]; + let chosenPath: string | null = top?.model_path && String(top.model_path).trim() ? top.model_path! : null; + if (!chosenPath) { + const nextWithModel = sorted.find((z) => z.model_path && String(z.model_path).trim()); + chosenPath = nextWithModel?.model_path ?? null; + } + + if (chosenPath) { + onSelectModel(chosenPath); + } else { + console.warn('No zone has a valid model_path to open.'); + } + } catch (error) { + console.error('Error selecting top zone model:', error); + } + }; + + const defaultButtons: ToolbarButton[] = [ + { + icon: '/icons/Zoom.png', + label: 'Zoom', + onClick: () => setIsZoomOpen(!isZoomOpen), + active: isZoomOpen, + children: [ + { + icon: '/icons/plus.svg', + label: 'Zoom In', + onClick: onZoomIn || (() => {}), + }, + { + icon: '/icons/minus.svg', + label: 'Zoom Out', + onClick: onZoomOut || (() => {}), + }, + ] + }, + { + icon: '/icons/Video.png', + label: "Top View", + onClick: onTopView || (() => console.log('Top View')), + }, + { + icon: '/icons/Pointer.png', + label: 'Pan', + onClick: onPan || (() => console.log('Pan')), + active: panActive, + }, + { + icon: '/icons/Warehouse.png', + label: 'Home', + onClick: handleHomeClick, + }, + { + icon: '/icons/Layers.png', + label: 'Levels', + onClick: handleToggleNavMenu, + active: navMenuActive, + }, + ]; + + + return ( +
+
+
+ {defaultButtons.map((button, index) => ( +
+ + {button.active && button.children && ( +
+ {button.children.map((childButton, childIndex) => ( + + ))} +
+ )} +
+ ))} +
+
+
+ ); +}; + +export default SceneToolbar; \ No newline at end of file diff --git a/frontend/public/icons/Eye.png b/frontend/public/icons/Eye.png new file mode 100644 index 0000000..6e346f1 Binary files /dev/null and b/frontend/public/icons/Eye.png differ