diff --git a/frontend/app/(protected)/navigation/page.tsx b/frontend/app/(protected)/navigation/page.tsx index b4302f1..a5177a8 100644 --- a/frontend/app/(protected)/navigation/page.tsx +++ b/frontend/app/(protected)/navigation/page.tsx @@ -163,10 +163,12 @@ const NavigationPage: React.FC = () => { 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) @@ -377,6 +379,25 @@ const NavigationPage: React.FC = () => { } }; + // Обработка focusSensorId из URL (при переходе из таблиц событий) + useEffect(() => { + if (urlFocusSensorId && isModelReady && detectorsData) { + console.log('[NavigationPage] Setting focusSensorId from URL:', urlFocusSensorId) + setFocusedSensorId(urlFocusSensorId) + setHighlightAllSensors(false) + + // Автоматически открываем тултип датчика + setTimeout(() => { + handleSensorSelection(urlFocusSensorId) + }, 500) // Задержка для полной инициализации + + // Очищаем URL от параметра после применения + const newUrl = new URL(window.location.href) + newUrl.searchParams.delete('focusSensorId') + window.history.replaceState({}, '', newUrl.toString()) + } + }, [urlFocusSensorId, isModelReady, detectorsData]) + const getStatusText = (status: string) => { const s = (status || '').toLowerCase() switch (s) { diff --git a/frontend/app/(protected)/navigation/page.tsx — копия 3 b/frontend/app/(protected)/navigation/page.tsx — копия 3 new file mode 100644 index 0000000..b4302f1 --- /dev/null +++ b/frontend/app/(protected)/navigation/page.tsx — копия 3 @@ -0,0 +1,615 @@ +'use client' + +import React, { useEffect, useCallback, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import Image from 'next/image' +import Sidebar from '../../../components/ui/Sidebar' +import AnimatedBackground from '../../../components/ui/AnimatedBackground' +import useNavigationStore from '../../store/navigationStore' +import Monitoring from '../../../components/navigation/Monitoring' +import FloorNavigation from '../../../components/navigation/FloorNavigation' +import DetectorMenu from '../../../components/navigation/DetectorMenu' +import ListOfDetectors from '../../../components/navigation/ListOfDetectors' +import Sensors from '../../../components/navigation/Sensors' +import AlertMenu from '../../../components/navigation/AlertMenu' +import Notifications from '../../../components/notifications/Notifications' +import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo' +import dynamic from 'next/dynamic' +import type { ModelViewerProps } from '../../../components/model/ModelViewer' +import * as statusColors from '../../../lib/statusColors' + +const ModelViewer = dynamic(() => import('../../../components/model/ModelViewer'), { + ssr: false, + loading: () => ( +
+
Загрузка 3D-модуля…
+
+ ), + }) + +interface DetectorType { + detector_id: number + name: string + serial_number: string + object: string + status: string + checked: boolean + type: string + detector_type: string + location: string + floor: number + notifications: Array<{ + id: number + type: string + message: string + timestamp: string + acknowledged: boolean + priority: string + }> +} + +interface NotificationType { + id: number + detector_id: number + detector_name: string + type: string + status: string + message: string + timestamp: string + location: string + object: string + acknowledged: boolean + priority: string +} + +interface AlertType { + id: number + detector_id: number + detector_name: string + type: string + status: string + message: string + timestamp: string + location: string + object: string + acknowledged: boolean + priority: string +} + +const NavigationPage: React.FC = () => { + const router = useRouter() + const searchParams = useSearchParams() + const { + currentObject, + setCurrentObject, + showMonitoring, + showFloorNavigation, + showNotifications, + showListOfDetectors, + showSensors, + selectedDetector, + showDetectorMenu, + selectedNotification, + showNotificationDetectorInfo, + selectedAlert, + showAlertMenu, + closeMonitoring, + closeFloorNavigation, + closeNotifications, + closeListOfDetectors, + closeSensors, + setSelectedDetector, + setShowDetectorMenu, + setSelectedNotification, + setShowNotificationDetectorInfo, + setSelectedAlert, + setShowAlertMenu, + showSensorHighlights, + toggleSensorHighlights + } = useNavigationStore() + + const [detectorsData, setDetectorsData] = useState<{ detectors: Record }>({ detectors: {} }) + const [detectorsError, setDetectorsError] = useState(null) + const [modelError, setModelError] = useState(null) + const [isModelReady, setIsModelReady] = useState(false) + const [focusedSensorId, setFocusedSensorId] = useState(null) + const [highlightAllSensors, setHighlightAllSensors] = useState(false) + const sensorStatusMap = React.useMemo(() => { + const map: Record = {} + Object.values(detectorsData.detectors).forEach(d => { + if (d.serial_number && d.status) { + map[String(d.serial_number).trim()] = d.status + } + }) + console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors') + console.log('[NavigationPage] Sample sensor IDs in map:', Object.keys(map).slice(0, 5)) + return map + }, [detectorsData]) + + useEffect(() => { + if (selectedDetector === null && selectedAlert === null) { + setFocusedSensorId(null); + } + }, [selectedDetector, selectedAlert]); + + // Управление выделением всех сенсоров при открытии/закрытии меню Sensors + // ИСПРАВЛЕНО: Подсветка датчиков остается включенной всегда, независимо от состояния панели Sensors + useEffect(() => { + console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady) + if (isModelReady) { + // Всегда включаем подсветку всех сенсоров когда модель готова + console.log('[NavigationPage] Setting highlightAllSensors to TRUE (always enabled)') + setHighlightAllSensors(true) + // Сбрасываем фокус только если панель Sensors закрыта + if (!showSensors) { + setFocusedSensorId(null) + } + } + }, [showSensors, isModelReady]) + + // Дополнительный эффект для задержки выделения сенсоров при открытии меню + // ИСПРАВЛЕНО: Задержка применяется только при открытии панели Sensors + useEffect(() => { + if (showSensors && isModelReady) { + const timer = setTimeout(() => { + console.log('[NavigationPage] Delayed highlightAllSensors to TRUE') + setHighlightAllSensors(true) + }, 500) // Задержка 500мс для полной инициализации модели + + return () => clearTimeout(timer) + } + }, [showSensors, isModelReady]) + + const urlObjectId = searchParams.get('objectId') + const urlObjectTitle = searchParams.get('objectTitle') + const urlModelPath = searchParams.get('modelPath') + const objectId = currentObject.id || urlObjectId + const objectTitle = currentObject.title || urlObjectTitle + const [selectedModelPath, setSelectedModelPath] = useState(urlModelPath || '') + + const handleModelLoaded = useCallback(() => { + setIsModelReady(true) + setModelError(null) + }, []) + + const handleModelError = useCallback((error: string) => { + console.error('[NavigationPage] Model loading error:', error) + setModelError(error) + setIsModelReady(false) + }, []) + + useEffect(() => { + if (selectedModelPath) { + setIsModelReady(false); + setModelError(null); + // Сохраняем выбранную модель в URL для восстановления при возврате + const params = new URLSearchParams(searchParams.toString()); + params.set('modelPath', selectedModelPath); + window.history.replaceState(null, '', `?${params.toString()}`); + } + }, [selectedModelPath, searchParams]); + + useEffect(() => { + if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) { + setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined) + } + }, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject]) + + // Восстановление выбранной модели из URL при загрузке страницы + useEffect(() => { + if (urlModelPath && !selectedModelPath) { + setSelectedModelPath(urlModelPath); + } + }, [urlModelPath, selectedModelPath]) + + useEffect(() => { + const loadDetectors = async () => { + try { + setDetectorsError(null) + const res = await fetch('/api/get-detectors', { cache: 'no-store' }) + const text = await res.text() + let payload: any + try { payload = JSON.parse(text) } catch { payload = text } + console.log('[NavigationPage] GET /api/get-detectors', { status: res.status, payload }) + if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов')) + const data = payload?.data ?? payload + const detectors = (data?.detectors ?? {}) as Record + console.log('[NavigationPage] Received detectors count:', Object.keys(detectors).length) + console.log('[NavigationPage] Sample detector keys:', Object.keys(detectors).slice(0, 5)) + setDetectorsData({ detectors }) + } catch (e: any) { + console.error('Ошибка загрузки детекторов:', e) + setDetectorsError(e?.message || 'Ошибка при загрузке детекторов') + } + } + loadDetectors() + }, []) + + const handleBackClick = () => { + router.push('/dashboard') + } + + const handleDetectorMenuClick = (detector: DetectorType) => { + // Для тестов. Выбор детектора. + console.log('[NavigationPage] Selected detector click:', { + detector_id: detector.detector_id, + name: detector.name, + serial_number: detector.serial_number, + }) + + // Проверяем, что детектор имеет необходимые данные + if (!detector || !detector.detector_id || !detector.serial_number) { + console.warn('[NavigationPage] Invalid detector data, skipping menu display:', detector) + return + } + + if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) { + closeDetectorMenu() + } else { + setSelectedDetector(detector) + setShowDetectorMenu(true) + setFocusedSensorId(detector.serial_number) + setShowAlertMenu(false) + setSelectedAlert(null) + // При открытии меню детектора - сбрасываем множественное выделение + setHighlightAllSensors(false) + } + } + + const closeDetectorMenu = () => { + setShowDetectorMenu(false) + setSelectedDetector(null) + setFocusedSensorId(null) + setSelectedAlert(null) + // При закрытии меню детектора - выделяем все сенсоры снова + setHighlightAllSensors(true) + } + + const handleNotificationClick = (notification: NotificationType) => { + if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) { + setShowNotificationDetectorInfo(false) + setSelectedNotification(null) + } else { + setSelectedNotification(notification) + setShowNotificationDetectorInfo(true) + } + } + + const closeNotificationDetectorInfo = () => { + setShowNotificationDetectorInfo(false) + setSelectedNotification(null) + } + + const closeAlertMenu = () => { + setShowAlertMenu(false) + setSelectedAlert(null) + setFocusedSensorId(null) + setSelectedDetector(null) + // При закрытии меню алерта - выделяем все сенсоры снова + setHighlightAllSensors(true) + } + + const handleAlertClick = (alert: AlertType) => { + console.log('[NavigationPage] Alert clicked, focusing on detector in 3D scene:', alert) + + const detector = Object.values(detectorsData.detectors).find( + d => d.detector_id === alert.detector_id + ) + + if (detector) { + if (selectedAlert?.id === alert.id && showAlertMenu) { + closeAlertMenu() + } else { + setSelectedAlert(alert) + setShowAlertMenu(true) + setFocusedSensorId(detector.serial_number) + setShowDetectorMenu(false) + setSelectedDetector(null) + // При открытии меню алерта - сбрасываем множественное выделение + setHighlightAllSensors(false) + console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name) + } + } else { + console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id) + } + } + + const handleSensorSelection = (serialNumber: string | null) => { + if (serialNumber === null) { + setFocusedSensorId(null); + closeDetectorMenu(); + closeAlertMenu(); + // If we're in Sensors menu and no sensor is selected, highlight all sensors + if (showSensors) { + setHighlightAllSensors(true); + } + return; + } + + if (focusedSensorId === serialNumber) { + setFocusedSensorId(null); + closeDetectorMenu(); + closeAlertMenu(); + // If we're in Sensors menu and deselected the current sensor, highlight all sensors + if (showSensors) { + setHighlightAllSensors(true); + } + return; + } + + // При выборе конкретного сенсора - сбрасываем множественное выделение + setHighlightAllSensors(false) + + const detector = Object.values(detectorsData?.detectors || {}).find( + (d) => d.serial_number === serialNumber + ); + + if (detector) { + if (showFloorNavigation || showListOfDetectors) { + handleDetectorMenuClick(detector); + } else if (detector.notifications && detector.notifications.length > 0) { + const sortedNotifications = [...detector.notifications].sort((a, b) => { + const priorityOrder: { [key: string]: number } = { critical: 0, warning: 1, info: 2 }; + return priorityOrder[a.priority.toLowerCase()] - priorityOrder[b.priority.toLowerCase()]; + }); + const notification = sortedNotifications[0]; + const alert: AlertType = { + ...notification, + detector_id: detector.detector_id, + detector_name: detector.name, + location: detector.location, + object: detector.object, + status: detector.status, + type: notification.type || 'info', + }; + handleAlertClick(alert); + } else { + handleDetectorMenuClick(detector); + } + } else { + setFocusedSensorId(null); + closeDetectorMenu(); + closeAlertMenu(); + // If we're in Sensors menu and no valid detector found, highlight all sensors + if (showSensors) { + setHighlightAllSensors(true); + } + } + }; + + const getStatusText = (status: string) => { + const s = (status || '').toLowerCase() + switch (s) { + case statusColors.STATUS_COLOR_CRITICAL: + case 'critical': + return 'Критический' + case statusColors.STATUS_COLOR_WARNING: + case 'warning': + return 'Предупреждение' + case statusColors.STATUS_COLOR_NORMAL: + case 'normal': + return 'Норма' + default: + return 'Неизвестно' + } + } + + return ( +
+ +
+ +
+ +
+ + {showMonitoring && ( +
+
+ { + console.log('[NavigationPage] Model selected:', path); + setSelectedModelPath(path) + setModelError(null) + setIsModelReady(false) + }} + /> +
+
+ )} + + {showFloorNavigation && ( +
+
+ +
+
+ )} + + {showNotifications && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showListOfDetectors && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showSensors && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showNotifications && showNotificationDetectorInfo && selectedNotification && (() => { + const detectorData = Object.values(detectorsData.detectors).find( + detector => detector.detector_id === selectedNotification.detector_id + ); + return detectorData ? ( +
+
+ +
+
+ ) : null; + })()} + + {showFloorNavigation && showDetectorMenu && selectedDetector && ( + null + )} + +
+
+
+ + +
+
+
+ +
+
+ {modelError ? ( + <> + {console.log('[NavigationPage] Rendering error message, modelError:', modelError)} +
+
+
+ Ошибка загрузки 3D модели +
+
+ {modelError} +
+
+ Используйте навигацию по этажам для просмотра детекторов +
+
+
+ + ) : !selectedModelPath ? ( +
+
+ AerBIM HT Monitor +
+ Выберите модель для отображения +
+
+
+ ) : ( + { + console.log('[NavigationPage] Model selected:', path); + setSelectedModelPath(path) + setModelError(null) + setIsModelReady(false) + }} + onModelLoaded={handleModelLoaded} + onError={handleModelError} + activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null} + focusSensorId={focusedSensorId} + highlightAllSensors={showSensorHighlights && highlightAllSensors} + sensorStatusMap={sensorStatusMap} + isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors} + onSensorPick={handleSensorSelection} + renderOverlay={({ anchor }) => ( + <> + {selectedAlert && showAlertMenu && anchor ? ( + + ) : selectedDetector && showDetectorMenu && anchor ? ( + + ) : null} + + )} + /> + )} +
+
+
+
+ ) +} + +export default NavigationPage diff --git a/frontend/app/store/navigationStore.ts b/frontend/app/store/navigationStore.ts index 49ec904..a43c9ed 100644 --- a/frontend/app/store/navigationStore.ts +++ b/frontend/app/store/navigationStore.ts @@ -117,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()( @@ -368,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/app/store/navigationStore — копия 2.ts b/frontend/app/store/navigationStore — копия 2.ts new file mode 100644 index 0000000..49ec904 --- /dev/null +++ b/frontend/app/store/navigationStore — копия 2.ts @@ -0,0 +1,380 @@ +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/components/alerts/AlertsList.tsx b/frontend/components/alerts/AlertsList.tsx index 1e7580a..e798040 100644 --- a/frontend/components/alerts/AlertsList.tsx +++ b/frontend/components/alerts/AlertsList.tsx @@ -1,4 +1,6 @@ import React, { useState, useMemo } from 'react' +import { useRouter } from 'next/navigation' +import useNavigationStore from '../../app/store/navigationStore' import * as statusColors from '../../lib/statusColors' interface AlertItem { @@ -12,6 +14,8 @@ interface AlertItem { detector_name?: string location?: string object?: string + serial_number?: string + floor?: number } interface AlertsListProps { @@ -21,6 +25,8 @@ interface AlertsListProps { } const AlertsList: React.FC = ({ alerts, onAcknowledgeToggle, initialSearchTerm = '' }) => { + const router = useRouter() + const { navigateToSensor } = useNavigationStore() const [searchTerm, setSearchTerm] = useState(initialSearchTerm) const filteredAlerts = useMemo(() => { @@ -46,6 +52,26 @@ const AlertsList: React.FC = ({ alerts, onAcknowledgeToggle, in } } + const handleGoTo3D = async (alert: AlertItem, viewType: 'building' | 'floor') => { + // Используем доступные идентификаторы датчика + const sensorId = alert.serial_number || alert.detector_name || alert.detector_id + + if (!sensorId) { + console.warn('[AlertsList] Alert missing sensor identifier:', alert) + return + } + + const sensorSerialNumber = await navigateToSensor( + sensorId, + alert.floor || null, + viewType + ) + + if (sensorSerialNumber) { + router.push(`/navigation?focusSensorId=${encodeURIComponent(sensorSerialNumber)}`) + } + } + return (
{/* Поиск */} @@ -81,6 +107,7 @@ const AlertsList: React.FC = ({ alerts, onAcknowledgeToggle, in Приоритет Подтверждено Время + 3D Вид @@ -141,11 +168,33 @@ const AlertsList: React.FC = ({ alerts, onAcknowledgeToggle, in {new Date(item.timestamp).toLocaleString('ru-RU')} + +
+ + +
+ ))} {filteredAlerts.length === 0 && ( - + Записей не найдено diff --git a/frontend/components/alerts/AlertsList.tsx — копия b/frontend/components/alerts/AlertsList.tsx — копия new file mode 100644 index 0000000..1e7580a --- /dev/null +++ b/frontend/components/alerts/AlertsList.tsx — копия @@ -0,0 +1,161 @@ +import React, { useState, useMemo } from 'react' +import * as statusColors from '../../lib/statusColors' + +interface AlertItem { + id: number + type: string + message: string + timestamp: string + acknowledged: boolean + priority: string + detector_id?: string + detector_name?: string + location?: string + object?: string +} + +interface AlertsListProps { + alerts: AlertItem[] + onAcknowledgeToggle: (alertId: number) => void + initialSearchTerm?: string +} + +const AlertsList: React.FC = ({ alerts, onAcknowledgeToggle, initialSearchTerm = '' }) => { + const [searchTerm, setSearchTerm] = useState(initialSearchTerm) + + const filteredAlerts = useMemo(() => { + return alerts.filter(alert => { + const matchesSearch = searchTerm === '' || alert.detector_id?.toString() === searchTerm + return matchesSearch + }) + }, [alerts, searchTerm]) + + const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 } + const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 } + + const getStatusColor = (type: string) => { + switch (type) { + case 'critical': + return statusColors.STATUS_COLOR_CRITICAL + case 'warning': + return statusColors.STATUS_COLOR_WARNING + case 'info': + return statusColors.STATUS_COLOR_NORMAL + default: + return statusColors.STATUS_COLOR_UNKNOWN + } + } + + return ( +
+ {/* Поиск */} +
+
+ setSearchTerm(e.target.value)} + className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64" + /> + + + +
+
+ + {/* Таблица алертов */} +
+
+

История тревог

+ Всего: {filteredAlerts.length} +
+
+ + + + + + + + + + + + + + {filteredAlerts.map((item) => ( + + + + + + + + + + ))} + {filteredAlerts.length === 0 && ( + + + + )} + +
ДетекторСтатусСообщениеМестоположениеПриоритетПодтвержденоВремя
+
{item.detector_name || 'Детектор'}
+ {item.detector_id ? ( +
ID: {item.detector_id}
+ ) : null} +
+
+
+ + {item.type === 'critical' ? 'Критический' : item.type === 'warning' ? 'Предупреждение' : 'Информация'} + +
+
+ {item.message} + + {item.location || '-'} + + + {item.priority === 'high' ? 'Высокий' : item.priority === 'medium' ? 'Средний' : 'Низкий'} + + + + {item.acknowledged ? 'Да' : 'Нет'} + + + + {new Date(item.timestamp).toLocaleString('ru-RU')} +
+ Записей не найдено +
+
+
+
+ ) +} + +export default AlertsList diff --git a/frontend/components/alerts/DetectorList.tsx b/frontend/components/alerts/DetectorList.tsx index 60eebe4..3d86a79 100644 --- a/frontend/components/alerts/DetectorList.tsx +++ b/frontend/components/alerts/DetectorList.tsx @@ -38,10 +38,37 @@ interface DetectorListProps { onDetectorSelect: (detectorId: number, selected: boolean) => void initialSearchTerm?: string } + +// Функция для генерации умного диапазона страниц +function getPaginationRange(currentPage: number, totalPages: number): (number | string)[] { + if (totalPages <= 7) { + // Если страниц мало - показываем все + return Array.from({ length: totalPages }, (_, i) => i + 1) + } + + // Всегда показываем первые 3 и последние 3 + const start = [1, 2, 3] + const end = [totalPages - 2, totalPages - 1, totalPages] + + if (currentPage <= 4) { + // Начало: 1 2 3 4 5 ... 11 12 13 + return [1, 2, 3, 4, 5, '...', ...end] + } + + if (currentPage >= totalPages - 3) { + // Конец: 1 2 3 ... 9 10 11 12 13 + return [...start, '...', totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages] + } + + // Середина: 1 2 3 ... 6 7 8 ... 11 12 13 + return [...start, '...', currentPage - 1, currentPage, currentPage + 1, '...', ...end] +} const DetectorList: React.FC = ({ objectId, selectedDetectors, onDetectorSelect, initialSearchTerm = '' }) => { const [detectors, setDetectors] = useState([]) const [searchTerm, setSearchTerm] = useState(initialSearchTerm) + const [currentPage, setCurrentPage] = useState(1) + const itemsPerPage = 20 useEffect(() => { const loadDetectors = async () => { @@ -76,6 +103,24 @@ const DetectorList: React.FC = ({ objectId, selectedDetectors return matchesSearch }) + + // Сброс на первую страницу при изменении поиска + useEffect(() => { + setCurrentPage(1) + }, [searchTerm]) + + // Пагинация + const totalPages = Math.ceil(filteredDetectors.length / itemsPerPage) + const startIndex = (currentPage - 1) * itemsPerPage + const endIndex = startIndex + itemsPerPage + const currentDetectors = filteredDetectors.slice(startIndex, endIndex) + const paginationRange = getPaginationRange(currentPage, totalPages) + + const handlePageChange = (page: number) => { + setCurrentPage(page) + // Скролл наверх таблицы + window.scrollTo({ top: 0, behavior: 'smooth' }) + } return (
@@ -109,16 +154,16 @@ const DetectorList: React.FC = ({ objectId, selectedDetectors 0} + checked={selectedDetectors.length === currentDetectors.length && currentDetectors.length > 0} onChange={(e) => { if (e.target.checked) { - filteredDetectors.forEach(detector => { + currentDetectors.forEach(detector => { if (!selectedDetectors.includes(detector.detector_id)) { onDetectorSelect(detector.detector_id, true) } }) } else { - filteredDetectors.forEach(detector => { + currentDetectors.forEach(detector => { if (selectedDetectors.includes(detector.detector_id)) { onDetectorSelect(detector.detector_id, false) } @@ -135,7 +180,7 @@ const DetectorList: React.FC = ({ objectId, selectedDetectors - {filteredDetectors.map((detector) => { + {currentDetectors.map((detector) => { const isSelected = selectedDetectors.includes(detector.detector_id) return ( @@ -183,9 +228,76 @@ const DetectorList: React.FC = ({ objectId, selectedDetectors
+ + {/* Пагинация */} + {totalPages > 1 && ( +
+ {/* Кнопки пагинации */} +
+ {/* Кнопка "Предыдущая" */} + + + {/* Номера страниц */} + {paginationRange.map((page, index) => { + if (page === '...') { + return ( + + ... + + ) + } + + const pageNumber = page as number + const isActive = pageNumber === currentPage + + return ( + + ) + })} + + {/* Кнопка "Следующая" */} + +
+ + {/* Счётчик */} +
+ Показано {startIndex + 1}-{Math.min(endIndex, filteredDetectors.length)} из {filteredDetectors.length} датчиков +
+
+ )}
- {/* Статы детекторров*/} + {/* Статы детекторов */}
{filteredDetectors.length}
diff --git a/frontend/components/dashboard/Dashboard.tsx b/frontend/components/dashboard/Dashboard.tsx index e169baa..6559533 100644 --- a/frontend/components/dashboard/Dashboard.tsx +++ b/frontend/components/dashboard/Dashboard.tsx @@ -12,7 +12,7 @@ import { aggregateChartDataByDays } 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) @@ -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 — копия 2 b/frontend/components/dashboard/Dashboard.tsx — копия 2 new file mode 100644 index 0000000..e169baa --- /dev/null +++ b/frontend/components/dashboard/Dashboard.tsx — копия 2 @@ -0,0 +1,324 @@ +'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 } = 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 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) => ( + + + + + + + + ))} + +
ДетекторСообщениеСерьезностьДатаРешен
{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/ModelViewer.tsx b/frontend/components/model/ModelViewer.tsx index 2ba0366..e9d79b7 100644 --- a/frontend/components/model/ModelViewer.tsx +++ b/frontend/components/model/ModelViewer.tsx @@ -20,6 +20,7 @@ import { PointerEventTypes, PointerInfo, Matrix, + Ray, } from '@babylonjs/core' import '@babylonjs/loaders' @@ -340,7 +341,8 @@ const ModelViewer: React.FC = ({ let engine: Engine try { - engine = new Engine(canvas, true, { stencil: true }) + // Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU + engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) const message = `WebGL недоступен: ${errorMessage}` @@ -362,6 +364,9 @@ const ModelViewer: React.FC = ({ sceneRef.current = scene scene.clearColor = new Color4(0.1, 0.1, 0.15, 1) + + // Оптимизация: включаем FXAA (более легковесное сглаживание) + scene.imageProcessingConfiguration.fxaaEnabled = true const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene) camera.attachControl(canvas, true) @@ -689,16 +694,65 @@ const ModelViewer: React.FC = ({ const maxDimension = Math.max(size.x, size.y, size.z) const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) + // Простое позиционирование камеры - всегда поворачиваемся к датчику + console.log('[ModelViewer] Calculating camera direction to sensor') + + // Вычисляем направление от текущей позиции камеры к датчику + const directionToSensor = center.subtract(camera.position).normalize() + + // Преобразуем в сферические координаты + // alpha - горизонтальный угол (вокруг оси Y) + let targetAlpha = Math.atan2(directionToSensor.x, directionToSensor.z) + + // beta - вертикальный угол (от вертикали) + // Используем оптимальный угол 60° для обзора + let targetBeta = Math.PI / 3 // 60° + + console.log('[ModelViewer] Calculated camera direction:', { + alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°', + beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°', + sensorPosition: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) }, + cameraPosition: { x: camera.position.x.toFixed(2), y: camera.position.y.toFixed(2), z: camera.position.z.toFixed(2) } + }) + + // Нормализуем alpha в диапазон [-PI, PI] + while (targetAlpha > Math.PI) targetAlpha -= 2 * Math.PI + while (targetAlpha < -Math.PI) targetAlpha += 2 * Math.PI + + // Ограничиваем beta в разумных пределах + targetBeta = Math.max(0.1, Math.min(Math.PI - 0.1, targetBeta)) + scene.stopAnimation(camera) + // Логирование перед анимацией + console.log('[ModelViewer] Starting camera animation:', { + sensorId, + from: { + target: { x: camera.target.x.toFixed(2), y: camera.target.y.toFixed(2), z: camera.target.z.toFixed(2) }, + radius: camera.radius.toFixed(2), + alpha: (camera.alpha * 180 / Math.PI).toFixed(1) + '°', + beta: (camera.beta * 180 / Math.PI).toFixed(1) + '°' + }, + to: { + target: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) }, + radius: targetRadius.toFixed(2), + alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°', + beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°' + }, + alphaChange: ((targetAlpha - camera.alpha) * 180 / Math.PI).toFixed(1) + '°', + betaChange: ((targetBeta - camera.beta) * 180 / Math.PI).toFixed(1) + '°' + }) + const ease = new CubicEase() ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) const frameRate = 60 - const durationMs = 600 + const durationMs = 800 const totalFrames = Math.round((durationMs / 1000) * frameRate) Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease) Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) + Animation.CreateAndStartAnimation('camAlpha', camera, 'alpha', frameRate, totalFrames, camera.alpha, targetAlpha, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) + Animation.CreateAndStartAnimation('camBeta', camera, 'beta', frameRate, totalFrames, camera.beta, targetBeta, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) applyHighlightToMeshes( highlightLayerRef.current, diff --git a/frontend/components/model/ModelViewer.tsx — копия 3 b/frontend/components/model/ModelViewer.tsx — копия 3 new file mode 100644 index 0000000..2ba0366 --- /dev/null +++ b/frontend/components/model/ModelViewer.tsx — копия 3 @@ -0,0 +1,914 @@ +'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 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, +}) => { + 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/ModelViewer.tsx — копия 4 b/frontend/components/model/ModelViewer.tsx — копия 4 new file mode 100644 index 0000000..51afe28 --- /dev/null +++ b/frontend/components/model/ModelViewer.tsx — копия 4 @@ -0,0 +1,940 @@ +'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 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, +}) => { + 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) + + // Вычисляем оптимальные углы камеры для видимости датчика + // Позиционируем камеру спереди датчика с небольшим наклоном сверху + const directionToCamera = camera.position.subtract(center).normalize() + + // Вычисляем целевые углы alpha и beta + // alpha - горизонтальный угол (вокруг оси Y) + // beta - вертикальный угол (наклон) + let targetAlpha = Math.atan2(directionToCamera.x, directionToCamera.z) + let targetBeta = Math.acos(directionToCamera.y) + + // Если датчик за стеной, позиционируем камеру спереди + // Используем направление от центра сцены к датчику + const sceneCenter = Vector3.Zero() + const directionFromSceneCenter = center.subtract(sceneCenter).normalize() + targetAlpha = Math.atan2(directionFromSceneCenter.x, directionFromSceneCenter.z) + Math.PI + targetBeta = Math.PI / 3 // 60 градусов - смотрим немного сверху + + // Нормализуем 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) + + const ease = new CubicEase() + ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) + const frameRate = 60 + const durationMs = 800 + const totalFrames = Math.round((durationMs / 1000) * frameRate) + + Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease) + Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) + Animation.CreateAndStartAnimation('camAlpha', camera, 'alpha', frameRate, totalFrames, camera.alpha, targetAlpha, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) + Animation.CreateAndStartAnimation('camBeta', camera, 'beta', frameRate, totalFrames, camera.beta, targetBeta, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) + + applyHighlightToMeshes( + highlightLayerRef.current, + highlightedMeshesRef, + [chosen], + mesh => { + const sid = getSensorIdFromMesh(mesh) + const status = sid ? sensorStatusMap?.[sid] : undefined + return statusToColor3(status ?? null) + }, + ) + chosenMeshRef.current = chosen + setOverlayData({ name: chosen.name, sensorId }) + } catch { + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] + highlightLayerRef.current?.removeAllMeshes() + chosenMeshRef.current = null + setOverlayPos(null) + setOverlayData(null) + } + } else { + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] + highlightLayerRef.current?.removeAllMeshes() + chosenMeshRef.current = null + setOverlayPos(null) + setOverlayData(null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusSensorId, modelReady, highlightAllSensors]) + + useEffect(() => { + const scene = sceneRef.current + if (!scene || !modelReady || !isSensorSelectionEnabled) return + + const pickObserver = scene.onPointerObservable.add((pointerInfo: PointerInfo) => { + if (pointerInfo.type !== PointerEventTypes.POINTERPICK) return + const pick = pointerInfo.pickInfo + if (!pick || !pick.hit) { + onSensorPick?.(null) + return + } + + const pickedMesh = pick.pickedMesh + const sensorId = getSensorIdFromMesh(pickedMesh) + + if (sensorId) { + onSensorPick?.(sensorId) + } else { + onSensorPick?.(null) + } + }) + + return () => { + scene.onPointerObservable.remove(pickObserver) + } + }, [modelReady, isSensorSelectionEnabled, onSensorPick]) + + const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => { + if (!sceneRef.current || !mesh) return null + const scene = sceneRef.current + try { + const bbox = (typeof mesh.getHierarchyBoundingVectors === 'function') + ? mesh.getHierarchyBoundingVectors() + : { min: mesh.getBoundingInfo().boundingBox.minimumWorld, max: mesh.getBoundingInfo().boundingBox.maximumWorld } + const center = bbox.min.add(bbox.max).scale(0.5) + + const viewport = scene.activeCamera?.viewport.toGlobal(engineRef.current!.getRenderWidth(), engineRef.current!.getRenderHeight()) + if (!viewport) return null + + const projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport) + if (!projected) return null + + return { left: projected.x, top: projected.y } + } catch (error) { + console.error('[ModelViewer] Error computing overlay position:', error) + return null + } + }, []) + + useEffect(() => { + if (!chosenMeshRef.current || !overlayData) return + const pos = computeOverlayPosition(chosenMeshRef.current) + setOverlayPos(pos) + }, [overlayData, computeOverlayPosition]) + + useEffect(() => { + if (!sceneRef.current || !chosenMeshRef.current || !overlayData) return + const scene = sceneRef.current + + const updateOverlayPosition = () => { + const pos = computeOverlayPosition(chosenMeshRef.current) + setOverlayPos(pos) + } + scene.registerBeforeRender(updateOverlayPosition) + return () => scene.unregisterBeforeRender(updateOverlayPosition) + }, [overlayData, computeOverlayPosition]) + + return ( +
+ {!modelPath ? ( +
+
+
+ 3D модель не выбрана +
+
+ Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр +
+
+ Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API +
+
+
+ ) : ( + <> + + {webglError ? ( +
+
+
+ 3D просмотр недоступен +
+
+ {webglError} +
+
+ Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве +
+
+
+ ) : isLoading ? ( +
+ +
+ ) : !modelReady ? ( +
+
+
+ 3D модель не загружена +
+
+ Модель не готова к отображению +
+
+
+ ) : null} + + + )} + {/* UPDATED: Interactive overlay circles with hover effects */} + {allSensorsOverlayCircles.map(circle => { + const size = 36 + const radius = size / 2 + const fill = hexWithAlpha(circle.colorHex, 0.2) + const isHovered = hoveredSensorId === circle.sensorId + + return ( +
handleOverlayCircleClick(circle.sensorId)} + onMouseEnter={() => setHoveredSensorId(circle.sensorId)} + onMouseLeave={() => setHoveredSensorId(null)} + style={{ + position: 'absolute', + left: circle.left - radius, + top: circle.top - radius, + width: size, + height: size, + borderRadius: '9999px', + border: `2px solid ${circle.colorHex}`, + backgroundColor: fill, + pointerEvents: 'auto', + cursor: 'pointer', + transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)', + transform: isHovered ? 'scale(1.4)' : 'scale(1)', + boxShadow: isHovered + ? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}` + : `0 0 8px ${circle.colorHex}`, + zIndex: isHovered ? 50 : 10, + }} + title={`Датчик: ${circle.sensorId}`} + /> + ) + })} + {renderOverlay && overlayPos && overlayData + ? renderOverlay({ anchor: overlayPos, info: overlayData }) + : null + } +
+ ) +} + +export default ModelViewer diff --git a/frontend/components/model/ModelViewer.tsx — копия 5 b/frontend/components/model/ModelViewer.tsx — копия 5 new file mode 100644 index 0000000..bccbded --- /dev/null +++ b/frontend/components/model/ModelViewer.tsx — копия 5 @@ -0,0 +1,944 @@ +'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 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, +}) => { + const canvasRef = useRef(null) + const engineRef = useRef>(null) + const sceneRef = useRef>(null) + const [isLoading, setIsLoading] = useState(false) + const [loadingProgress, setLoadingProgress] = useState(0) + const [showModel, setShowModel] = useState(false) + const isInitializedRef = useRef(false) + const isDisposedRef = useRef(false) + const importedMeshesRef = useRef([]) + const highlightLayerRef = useRef(null) + const highlightedMeshesRef = useRef([]) + const chosenMeshRef = useRef(null) + const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null) + const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null) + const [modelReady, setModelReady] = useState(false) + const [panActive, setPanActive] = useState(false); + const [webglError, setWebglError] = useState(null) + const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState< + { sensorId: string; left: number; top: number; colorHex: string }[] + >([]) + // NEW: State for tracking hovered sensor in overlay circles + const [hoveredSensorId, setHoveredSensorId] = useState(null) + + const handlePan = () => setPanActive(!panActive); + + useEffect(() => { + const scene = sceneRef.current; + const camera = scene?.activeCamera as ArcRotateCamera; + const canvas = canvasRef.current; + + if (!scene || !camera || !canvas) { + return; + } + + let observer: any = null; + + if (panActive) { + camera.detachControl(); + + observer = scene.onPointerObservable.add((pointerInfo: PointerInfo) => { + const evt = pointerInfo.event; + + if (evt.buttons === 1) { + camera.inertialPanningX -= evt.movementX / camera.panningSensibility; + camera.inertialPanningY += evt.movementY / camera.panningSensibility; + } + else if (evt.buttons === 2) { + camera.inertialAlphaOffset -= evt.movementX / camera.angularSensibilityX; + camera.inertialBetaOffset -= evt.movementY / camera.angularSensibilityY; + } + }, PointerEventTypes.POINTERMOVE); + + } else { + camera.detachControl(); + camera.attachControl(canvas, true); + } + + return () => { + if (observer) { + scene.onPointerObservable.remove(observer); + } + if (!camera.isDisposed() && !camera.inputs.attachedToElement) { + camera.attachControl(canvas, true); + } + }; + }, [panActive, sceneRef, canvasRef]); + + const handleZoomIn = () => { + const camera = sceneRef.current?.activeCamera as ArcRotateCamera + if (camera) { + sceneRef.current?.stopAnimation(camera) + const ease = new CubicEase() + ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT) + + const frameRate = 60 + const durationMs = 300 + const totalFrames = Math.round((durationMs / 1000) * frameRate) + + const currentRadius = camera.radius + const targetRadius = Math.max(camera.lowerRadiusLimit ?? 0.1, currentRadius * 0.8) + + Animation.CreateAndStartAnimation( + 'zoomIn', + camera, + 'radius', + frameRate, + totalFrames, + currentRadius, + targetRadius, + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ) + } + } + const handleZoomOut = () => { + const camera = sceneRef.current?.activeCamera as ArcRotateCamera + if (camera) { + sceneRef.current?.stopAnimation(camera) + const ease = new CubicEase() + ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT) + + const frameRate = 60 + const durationMs = 300 + const totalFrames = Math.round((durationMs / 1000) * frameRate) + + const currentRadius = camera.radius + const targetRadius = Math.min(camera.upperRadiusLimit ?? Infinity, currentRadius * 1.2) + + Animation.CreateAndStartAnimation( + 'zoomOut', + camera, + 'radius', + frameRate, + totalFrames, + currentRadius, + targetRadius, + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ) + } + } + const handleTopView = () => { + const camera = sceneRef.current?.activeCamera as ArcRotateCamera; + if (camera) { + sceneRef.current?.stopAnimation(camera); + const ease = new CubicEase(); + ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT); + + const frameRate = 60; + const durationMs = 500; + const totalFrames = Math.round((durationMs / 1000) * frameRate); + + Animation.CreateAndStartAnimation( + 'topViewAlpha', + camera, + 'alpha', + frameRate, + totalFrames, + camera.alpha, + Math.PI / 2, + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ); + + Animation.CreateAndStartAnimation( + 'topViewBeta', + camera, + 'beta', + frameRate, + totalFrames, + camera.beta, + 0, + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ); + } + }; + + // NEW: Function to handle overlay circle click + const handleOverlayCircleClick = (sensorId: string) => { + console.log('[ModelViewer] Overlay circle clicked:', sensorId) + + // Find the mesh for this sensor + const allMeshes = importedMeshesRef.current || [] + const sensorMeshes = collectSensorMeshes(allMeshes) + const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId) + + if (!targetMesh) { + console.warn(`[ModelViewer] Mesh not found for sensor: ${sensorId}`) + return + } + + const scene = sceneRef.current + const camera = scene?.activeCamera as ArcRotateCamera + if (!scene || !camera) return + + // Calculate bounding box of the sensor mesh + const bbox = (typeof targetMesh.getHierarchyBoundingVectors === 'function') + ? targetMesh.getHierarchyBoundingVectors() + : { + min: targetMesh.getBoundingInfo().boundingBox.minimumWorld, + max: targetMesh.getBoundingInfo().boundingBox.maximumWorld + } + + const center = bbox.min.add(bbox.max).scale(0.5) + const size = bbox.max.subtract(bbox.min) + const maxDimension = Math.max(size.x, size.y, size.z) + + // Calculate optimal camera distance + const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) + + // Stop any current animations + scene.stopAnimation(camera) + + // Setup easing + const ease = new CubicEase() + ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) + + const frameRate = 60 + const durationMs = 600 // 0.6 seconds for smooth animation + const totalFrames = Math.round((durationMs / 1000) * frameRate) + + // Animate camera target position + Animation.CreateAndStartAnimation( + 'camTarget', + camera, + 'target', + frameRate, + totalFrames, + camera.target.clone(), + center.clone(), + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ) + + // Animate camera radius (zoom) + Animation.CreateAndStartAnimation( + 'camRadius', + camera, + 'radius', + frameRate, + totalFrames, + camera.radius, + targetRadius, + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ) + + // Call callback to display tooltip + onSensorPick?.(sensorId) + + console.log('[ModelViewer] Camera animation started for sensor:', sensorId) + } + + useEffect(() => { + isDisposedRef.current = false + isInitializedRef.current = false + return () => { + isDisposedRef.current = true + } + }, []) + + useEffect(() => { + if (!canvasRef.current || isInitializedRef.current) return + + const canvas = canvasRef.current + setWebglError(null) + + let hasWebGL = false + try { + const testCanvas = document.createElement('canvas') + const gl = + testCanvas.getContext('webgl2') || + testCanvas.getContext('webgl') || + testCanvas.getContext('experimental-webgl') + hasWebGL = !!gl + } catch { + hasWebGL = false + } + + if (!hasWebGL) { + const message = 'WebGL не поддерживается в текущем окружении' + setWebglError(message) + onError?.(message) + setIsLoading(false) + setModelReady(false) + return + } + + let engine: Engine + try { + // Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU + engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const message = `WebGL недоступен: ${errorMessage}` + setWebglError(message) + onError?.(message) + setIsLoading(false) + setModelReady(false) + return + } + engineRef.current = engine + + engine.runRenderLoop(() => { + if (!isDisposedRef.current && sceneRef.current) { + sceneRef.current.render() + } + }) + + const scene = new Scene(engine) + sceneRef.current = scene + + scene.clearColor = new Color4(0.1, 0.1, 0.15, 1) + + // Оптимизация: включаем FXAA (более легковесное сглаживание) + scene.imageProcessingConfiguration.fxaaEnabled = true + + const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene) + camera.attachControl(canvas, true) + camera.lowerRadiusLimit = 2 + camera.upperRadiusLimit = 200 + camera.wheelDeltaPercentage = 0.01 + camera.panningSensibility = 50 + camera.angularSensibilityX = 1000 + camera.angularSensibilityY = 1000 + + const ambientLight = new HemisphericLight('ambientLight', new Vector3(0, 1, 0), scene) + ambientLight.intensity = 0.4 + ambientLight.diffuse = new Color3(0.7, 0.7, 0.8) + ambientLight.specular = new Color3(0.2, 0.2, 0.3) + ambientLight.groundColor = new Color3(0.3, 0.3, 0.4) + + const keyLight = new HemisphericLight('keyLight', new Vector3(1, 1, 0), scene) + keyLight.intensity = 0.6 + keyLight.diffuse = new Color3(1, 1, 0.9) + keyLight.specular = new Color3(1, 1, 0.9) + + const fillLight = new HemisphericLight('fillLight', new Vector3(-1, 0.5, -1), scene) + fillLight.intensity = 0.3 + fillLight.diffuse = new Color3(0.8, 0.8, 1) + + const hl = new HighlightLayer('highlight-layer', scene, { + mainTextureRatio: 1, + blurTextureSizeRatio: 1, + }) + hl.innerGlow = false + hl.outerGlow = true + hl.blurHorizontalSize = 2 + hl.blurVerticalSize = 2 + highlightLayerRef.current = hl + + const handleResize = () => { + if (!isDisposedRef.current) { + engine.resize() + } + } + window.addEventListener('resize', handleResize) + + isInitializedRef.current = true + + return () => { + isDisposedRef.current = true + isInitializedRef.current = false + window.removeEventListener('resize', handleResize) + + highlightLayerRef.current?.dispose() + highlightLayerRef.current = null + if (engineRef.current) { + engineRef.current.dispose() + engineRef.current = null + } + sceneRef.current = null + } + }, [onError]) + + useEffect(() => { + if (!modelPath || !sceneRef.current || !engineRef.current) return + + const scene = sceneRef.current + + setIsLoading(true) + setLoadingProgress(0) + setShowModel(false) + setModelReady(false) + + const loadModel = async () => { + try { + console.log('[ModelViewer] Starting to load model:', modelPath) + + // UI элемент загрузчика (есть эффект замедленности) + const progressInterval = setInterval(() => { + setLoadingProgress(prev => { + if (prev >= 90) { + clearInterval(progressInterval) + return 90 + } + return prev + Math.random() * 15 + }) + }, 100) + + // Use the correct ImportMeshAsync signature: (url, scene, onProgress) + const result = await ImportMeshAsync(modelPath, scene, (evt) => { + if (evt.lengthComputable) { + const progress = (evt.loaded / evt.total) * 100 + setLoadingProgress(progress) + console.log('[ModelViewer] Loading progress:', progress) + } + }) + + clearInterval(progressInterval) + + if (isDisposedRef.current) { + console.log('[ModelViewer] Component disposed during load') + return + } + + console.log('[ModelViewer] Model loaded successfully:', { + meshesCount: result.meshes.length, + particleSystemsCount: result.particleSystems.length, + skeletonsCount: result.skeletons.length, + animationGroupsCount: result.animationGroups.length + }) + + importedMeshesRef.current = result.meshes + + if (result.meshes.length > 0) { + const boundingBox = result.meshes[0].getHierarchyBoundingVectors() + + onModelLoaded?.({ + meshes: result.meshes, + boundingBox: { + min: { x: boundingBox.min.x, y: boundingBox.min.y, z: boundingBox.min.z }, + max: { x: boundingBox.max.x, y: boundingBox.max.y, z: boundingBox.max.z }, + }, + }) + + // Автоматическое кадрирование камеры для отображения всей модели + const camera = scene.activeCamera as ArcRotateCamera + if (camera) { + const center = boundingBox.min.add(boundingBox.max).scale(0.5) + const size = boundingBox.max.subtract(boundingBox.min) + const maxDimension = Math.max(size.x, size.y, size.z) + + // Устанавливаем оптимальное расстояние камеры + const targetRadius = maxDimension * 2.5 // Множитель для комфортного отступа + + // Плавная анимация камеры к центру модели + scene.stopAnimation(camera) + + const ease = new CubicEase() + ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) + + const frameRate = 60 + const durationMs = 800 // 0.8 секунды + const totalFrames = Math.round((durationMs / 1000) * frameRate) + + // Анимация позиции камеры + Animation.CreateAndStartAnimation( + 'frameCameraTarget', + camera, + 'target', + frameRate, + totalFrames, + camera.target.clone(), + center.clone(), + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ) + + // Анимация зума + Animation.CreateAndStartAnimation( + 'frameCameraRadius', + camera, + 'radius', + frameRate, + totalFrames, + camera.radius, + targetRadius, + Animation.ANIMATIONLOOPMODE_CONSTANT, + ease + ) + + console.log('[ModelViewer] Camera framed to model:', { center, targetRadius, maxDimension }) + } + } + + setLoadingProgress(100) + setShowModel(true) + setModelReady(true) + setIsLoading(false) + } catch (error) { + if (isDisposedRef.current) return + const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка' + console.error('[ModelViewer] Error loading model:', errorMessage) + const message = `Ошибка при загрузке модели: ${errorMessage}` + onError?.(message) + setIsLoading(false) + setModelReady(false) + } + } + + loadModel() + }, [modelPath, onModelLoaded, onError]) + + useEffect(() => { + if (!highlightAllSensors || focusSensorId || !modelReady) { + setAllSensorsOverlayCircles([]) + return + } + + const scene = sceneRef.current + const engine = engineRef.current + if (!scene || !engine) { + setAllSensorsOverlayCircles([]) + return + } + + const allMeshes = importedMeshesRef.current || [] + const sensorMeshes = collectSensorMeshes(allMeshes) + if (sensorMeshes.length === 0) { + setAllSensorsOverlayCircles([]) + return + } + + const engineTyped = engine as Engine + const updateCircles = () => { + const circles = computeSensorOverlayCircles({ + scene, + engine: engineTyped, + meshes: sensorMeshes, + sensorStatusMap, + }) + setAllSensorsOverlayCircles(circles) + } + + updateCircles() + const observer = scene.onBeforeRenderObservable.add(updateCircles) + return () => { + scene.onBeforeRenderObservable.remove(observer) + setAllSensorsOverlayCircles([]) + } + }, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap]) + + useEffect(() => { + if (!highlightAllSensors || focusSensorId || !modelReady) { + return + } + + const scene = sceneRef.current + if (!scene) return + + const allMeshes = importedMeshesRef.current || [] + if (allMeshes.length === 0) { + return + } + + const sensorMeshes = collectSensorMeshes(allMeshes) + + console.log('[ModelViewer] Total meshes in model:', allMeshes.length) + console.log('[ModelViewer] Sensor meshes found:', sensorMeshes.length) + + // Log first 5 sensor IDs found in meshes + const sensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean).slice(0, 5) + console.log('[ModelViewer] Sample sensor IDs from meshes:', sensorIds) + + if (sensorMeshes.length === 0) { + console.warn('[ModelViewer] No sensor meshes found in 3D model!') + return + } + + applyHighlightToMeshes( + highlightLayerRef.current, + highlightedMeshesRef, + sensorMeshes, + mesh => { + const sid = getSensorIdFromMesh(mesh) + const status = sid ? sensorStatusMap?.[sid] : undefined + return statusToColor3(status ?? null) + }, + ) + }, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap]) + + useEffect(() => { + if (!focusSensorId || !modelReady) { + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] + highlightLayerRef.current?.removeAllMeshes() + chosenMeshRef.current = null + setOverlayPos(null) + setOverlayData(null) + setAllSensorsOverlayCircles([]) + return + } + + const sensorId = (focusSensorId ?? '').trim() + if (!sensorId) { + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] + highlightLayerRef.current?.removeAllMeshes() + chosenMeshRef.current = null + setOverlayPos(null) + setOverlayData(null) + return + } + + const allMeshes = importedMeshesRef.current || [] + + if (allMeshes.length === 0) { + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] + highlightLayerRef.current?.removeAllMeshes() + chosenMeshRef.current = null + setOverlayPos(null) + setOverlayData(null) + return + } + + const sensorMeshes = collectSensorMeshes(allMeshes) + const allSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)) + const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId) + + console.log('[ModelViewer] Sensor focus', { + requested: sensorId, + totalImportedMeshes: allMeshes.length, + totalSensorMeshes: sensorMeshes.length, + allSensorIds: allSensorIds, + chosen: chosen ? { id: chosen.id, name: chosen.name, uniqueId: chosen.uniqueId, parent: chosen.parent?.name } : null, + source: 'result.meshes', + }) + + const scene = sceneRef.current! + + if (chosen) { + try { + const camera = scene.activeCamera as ArcRotateCamera + const bbox = (typeof chosen.getHierarchyBoundingVectors === 'function') + ? chosen.getHierarchyBoundingVectors() + : { min: chosen.getBoundingInfo().boundingBox.minimumWorld, max: chosen.getBoundingInfo().boundingBox.maximumWorld } + const center = bbox.min.add(bbox.max).scale(0.5) + const size = bbox.max.subtract(bbox.min) + const maxDimension = Math.max(size.x, size.y, size.z) + const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) + + // Вычисляем оптимальные углы камеры для видимости датчика + // Позиционируем камеру спереди датчика с небольшим наклоном сверху + const directionToCamera = camera.position.subtract(center).normalize() + + // Вычисляем целевые углы alpha и beta + // alpha - горизонтальный угол (вокруг оси Y) + // beta - вертикальный угол (наклон) + let targetAlpha = Math.atan2(directionToCamera.x, directionToCamera.z) + let targetBeta = Math.acos(directionToCamera.y) + + // Если датчик за стеной, позиционируем камеру спереди + // Используем направление от центра сцены к датчику + const sceneCenter = Vector3.Zero() + const directionFromSceneCenter = center.subtract(sceneCenter).normalize() + targetAlpha = Math.atan2(directionFromSceneCenter.x, directionFromSceneCenter.z) + Math.PI + targetBeta = Math.PI / 3 // 60 градусов - смотрим немного сверху + + // Нормализуем 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) + + const ease = new CubicEase() + ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) + const frameRate = 60 + const durationMs = 800 + const totalFrames = Math.round((durationMs / 1000) * frameRate) + + Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease) + Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) + Animation.CreateAndStartAnimation('camAlpha', camera, 'alpha', frameRate, totalFrames, camera.alpha, targetAlpha, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) + Animation.CreateAndStartAnimation('camBeta', camera, 'beta', frameRate, totalFrames, camera.beta, targetBeta, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) + + applyHighlightToMeshes( + highlightLayerRef.current, + highlightedMeshesRef, + [chosen], + mesh => { + const sid = getSensorIdFromMesh(mesh) + const status = sid ? sensorStatusMap?.[sid] : undefined + return statusToColor3(status ?? null) + }, + ) + chosenMeshRef.current = chosen + setOverlayData({ name: chosen.name, sensorId }) + } catch { + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] + highlightLayerRef.current?.removeAllMeshes() + chosenMeshRef.current = null + setOverlayPos(null) + setOverlayData(null) + } + } else { + for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } + highlightedMeshesRef.current = [] + highlightLayerRef.current?.removeAllMeshes() + chosenMeshRef.current = null + setOverlayPos(null) + setOverlayData(null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusSensorId, modelReady, highlightAllSensors]) + + useEffect(() => { + const scene = sceneRef.current + if (!scene || !modelReady || !isSensorSelectionEnabled) return + + const pickObserver = scene.onPointerObservable.add((pointerInfo: PointerInfo) => { + if (pointerInfo.type !== PointerEventTypes.POINTERPICK) return + const pick = pointerInfo.pickInfo + if (!pick || !pick.hit) { + onSensorPick?.(null) + return + } + + const pickedMesh = pick.pickedMesh + const sensorId = getSensorIdFromMesh(pickedMesh) + + if (sensorId) { + onSensorPick?.(sensorId) + } else { + onSensorPick?.(null) + } + }) + + return () => { + scene.onPointerObservable.remove(pickObserver) + } + }, [modelReady, isSensorSelectionEnabled, onSensorPick]) + + const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => { + if (!sceneRef.current || !mesh) return null + const scene = sceneRef.current + try { + const bbox = (typeof mesh.getHierarchyBoundingVectors === 'function') + ? mesh.getHierarchyBoundingVectors() + : { min: mesh.getBoundingInfo().boundingBox.minimumWorld, max: mesh.getBoundingInfo().boundingBox.maximumWorld } + const center = bbox.min.add(bbox.max).scale(0.5) + + const viewport = scene.activeCamera?.viewport.toGlobal(engineRef.current!.getRenderWidth(), engineRef.current!.getRenderHeight()) + if (!viewport) return null + + const projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport) + if (!projected) return null + + return { left: projected.x, top: projected.y } + } catch (error) { + console.error('[ModelViewer] Error computing overlay position:', error) + return null + } + }, []) + + useEffect(() => { + if (!chosenMeshRef.current || !overlayData) return + const pos = computeOverlayPosition(chosenMeshRef.current) + setOverlayPos(pos) + }, [overlayData, computeOverlayPosition]) + + useEffect(() => { + if (!sceneRef.current || !chosenMeshRef.current || !overlayData) return + const scene = sceneRef.current + + const updateOverlayPosition = () => { + const pos = computeOverlayPosition(chosenMeshRef.current) + setOverlayPos(pos) + } + scene.registerBeforeRender(updateOverlayPosition) + return () => scene.unregisterBeforeRender(updateOverlayPosition) + }, [overlayData, computeOverlayPosition]) + + return ( +
+ {!modelPath ? ( +
+
+
+ 3D модель не выбрана +
+
+ Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр +
+
+ Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API +
+
+
+ ) : ( + <> + + {webglError ? ( +
+
+
+ 3D просмотр недоступен +
+
+ {webglError} +
+
+ Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве +
+
+
+ ) : isLoading ? ( +
+ +
+ ) : !modelReady ? ( +
+
+
+ 3D модель не загружена +
+
+ Модель не готова к отображению +
+
+
+ ) : null} + + + )} + {/* UPDATED: Interactive overlay circles with hover effects */} + {allSensorsOverlayCircles.map(circle => { + const size = 36 + const radius = size / 2 + const fill = hexWithAlpha(circle.colorHex, 0.2) + const isHovered = hoveredSensorId === circle.sensorId + + return ( +
handleOverlayCircleClick(circle.sensorId)} + onMouseEnter={() => setHoveredSensorId(circle.sensorId)} + onMouseLeave={() => setHoveredSensorId(null)} + style={{ + position: 'absolute', + left: circle.left - radius, + top: circle.top - radius, + width: size, + height: size, + borderRadius: '9999px', + border: `2px solid ${circle.colorHex}`, + backgroundColor: fill, + pointerEvents: 'auto', + cursor: 'pointer', + transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)', + transform: isHovered ? 'scale(1.4)' : 'scale(1)', + boxShadow: isHovered + ? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}` + : `0 0 8px ${circle.colorHex}`, + zIndex: isHovered ? 50 : 10, + }} + title={`Датчик: ${circle.sensorId}`} + /> + ) + })} + {renderOverlay && overlayPos && overlayData + ? renderOverlay({ anchor: overlayPos, info: overlayData }) + : null + } +
+ ) +} + +export default ModelViewer diff --git a/frontend/components/model/SceneToolbar.tsx b/frontend/components/model/SceneToolbar.tsx index 3be2125..c4809e5 100644 --- a/frontend/components/model/SceneToolbar.tsx +++ b/frontend/components/model/SceneToolbar.tsx @@ -47,50 +47,6 @@ const SceneToolbar: React.FC = ({ } }; - 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', @@ -127,11 +83,6 @@ const SceneToolbar: React.FC = ({ onClick: onToggleSensorHighlights || (() => console.log('Toggle Sensor Highlights')), active: sensorHighlightsActive, }, - { - icon: '/icons/Warehouse.png', - label: 'Домой', - onClick: handleHomeClick, - }, { icon: '/icons/Layers.png', label: 'Уровни', diff --git a/frontend/components/model/SceneToolbar.tsx — копия 2 b/frontend/components/model/SceneToolbar.tsx — копия 2 new file mode 100644 index 0000000..3be2125 --- /dev/null +++ b/frontend/components/model/SceneToolbar.tsx — копия 2 @@ -0,0 +1,217 @@ +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; + onToggleSensorHighlights?: () => void; + sensorHighlightsActive?: boolean; +} + +const SceneToolbar: React.FC = ({ + onZoomIn, + onZoomOut, + onTopView, + onPan, + onSelectModel, + panActive = false, + navMenuActive = false, + onToggleSensorHighlights, + sensorHighlightsActive = true, +}) => { + 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: 'Масштаб', + onClick: () => setIsZoomOpen(!isZoomOpen), + active: isZoomOpen, + children: [ + { + icon: '/icons/plus.svg', + label: 'Приблизить', + onClick: onZoomIn || (() => {}), + }, + { + icon: '/icons/minus.svg', + label: 'Отдалить', + onClick: onZoomOut || (() => {}), + }, + ] + }, + { + icon: '/icons/Video.png', + label: 'Вид сверху', + onClick: onTopView || (() => console.log('Top View')), + }, + { + icon: '/icons/Pointer.png', + 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: 'Домой', + onClick: handleHomeClick, + }, + { + icon: '/icons/Layers.png', + label: 'Уровни', + 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/components/navigation/Monitoring.tsx b/frontend/components/navigation/Monitoring.tsx index 19a68da..1cc0150 100644 --- a/frontend/components/navigation/Monitoring.tsx +++ b/frontend/components/navigation/Monitoring.tsx @@ -35,13 +35,22 @@ interface MonitoringProps { } const Monitoring: React.FC = ({ onClose, onSelectModel }) => { - const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore(); + const { currentObject, currentZones, zonesLoading, zonesError, loadZones, currentModelPath } = useNavigationStore(); + const [autoSelectedRef, setAutoSelectedRef] = React.useState(false); const handleSelectModel = useCallback((modelPath: string) => { console.log(`[Monitoring] Model selected: ${modelPath}`); console.log(`[Monitoring] onSelectModel callback:`, onSelectModel); onSelectModel?.(modelPath); - }, [onSelectModel]); + + // Автоматически закрываем панель после выбора модели + if (onClose) { + setTimeout(() => { + console.log('[Monitoring] Auto-closing after model selection'); + onClose(); + }, 100); + } + }, [onSelectModel, onClose]); // Загрузка зон при изменении объекта useEffect(() => { @@ -51,7 +60,19 @@ const Monitoring: React.FC = ({ onClose, onSelectModel }) => { loadZones(objId); }, [currentObject?.id, loadZones]); - // Автоматический выбор первой зоны ОТКЛЮЧЕН - пользователь должен выбрать модель вручную + // Автоматический выбор модели, если currentModelPath установлен (переход из таблицы) + useEffect(() => { + if (!currentModelPath || autoSelectedRef || !onSelectModel) return; + + console.log('[Monitoring] Auto-selecting model from currentModelPath:', currentModelPath); + setAutoSelectedRef(true); + onSelectModel(currentModelPath); + }, [currentModelPath, autoSelectedRef, onSelectModel]); + + // Сброс флага при изменении объекта + useEffect(() => { + setAutoSelectedRef(false); + }, [currentObject?.id]) const sortedZones: Zone[] = React.useMemo(() => { const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => { diff --git a/frontend/components/navigation/Monitoring.tsx — копия 4 b/frontend/components/navigation/Monitoring.tsx — копия 4 new file mode 100644 index 0000000..1cc0150 --- /dev/null +++ b/frontend/components/navigation/Monitoring.tsx — копия 4 @@ -0,0 +1,201 @@ +import React, { useEffect, useCallback, useState } from 'react'; +import Image from 'next/image'; +import useNavigationStore from '@/app/store/navigationStore'; +import type { Zone } from '@/app/types'; + +// Безопасный резолвер src изображения, чтобы избежать ошибок Invalid URL в next/image +const resolveImageSrc = (src?: string | null): string => { + if (!src || typeof src !== 'string') return '/images/test_image.png'; + let s = src.trim(); + if (!s) return '/images/test_image.png'; + s = s.replace(/\\/g, '/'); + const lower = s.toLowerCase(); + // Явный плейсхолдер test_image.png маппим на наш статический ресурс + if (lower === 'test_image.png' || lower.endsWith('/test_image.png') || lower.includes('/public/images/test_image.png')) { + return '/images/test_image.png'; + } + // Если путь содержит public/images (даже абсолютный путь ФС), переводим в относительный путь сайта + if (/\/public\/images\//i.test(s)) { + const parts = s.split(/\/public\/images\//i); + const rel = parts[1] || ''; + return `/images/${rel}`; + } + // Абсолютные URL и пути, относительные к сайту + if (s.startsWith('http://') || s.startsWith('https://')) return s; + if (s.startsWith('/')) return s; + // Нормализуем относительные имена ресурсов до путей сайта под /images + // Убираем ведущий 'public/', если он присутствует + s = s.replace(/^public\//i, ''); + return s.startsWith('images/') ? `/${s}` : `/images/${s}`; +} + +interface MonitoringProps { + onClose?: () => void; + onSelectModel?: (modelPath: string) => void; +} + +const Monitoring: React.FC = ({ onClose, onSelectModel }) => { + const { currentObject, currentZones, zonesLoading, zonesError, loadZones, currentModelPath } = useNavigationStore(); + const [autoSelectedRef, setAutoSelectedRef] = React.useState(false); + + const handleSelectModel = useCallback((modelPath: string) => { + console.log(`[Monitoring] Model selected: ${modelPath}`); + console.log(`[Monitoring] onSelectModel callback:`, onSelectModel); + onSelectModel?.(modelPath); + + // Автоматически закрываем панель после выбора модели + if (onClose) { + setTimeout(() => { + console.log('[Monitoring] Auto-closing after model selection'); + onClose(); + }, 100); + } + }, [onSelectModel, onClose]); + + // Загрузка зон при изменении объекта + useEffect(() => { + const objId = currentObject?.id; + if (!objId) return; + console.log(`[Monitoring] Loading zones for object ID: ${objId}`); + loadZones(objId); + }, [currentObject?.id, loadZones]); + + // Автоматический выбор модели, если currentModelPath установлен (переход из таблицы) + useEffect(() => { + if (!currentModelPath || autoSelectedRef || !onSelectModel) return; + + console.log('[Monitoring] Auto-selecting model from currentModelPath:', currentModelPath); + setAutoSelectedRef(true); + onSelectModel(currentModelPath); + }, [currentModelPath, autoSelectedRef, onSelectModel]); + + // Сброс флага при изменении объекта + useEffect(() => { + setAutoSelectedRef(false); + }, [currentObject?.id]) + + const sortedZones: Zone[] = React.useMemo(() => { + const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => { + const oa = typeof a.order === 'number' ? a.order : 0; + const ob = typeof b.order === 'number' ? b.order : 0; + if (oa !== ob) return oa - ob; + return (a.name || '').localeCompare(b.name || ''); + }); + console.log(`[Monitoring] Sorted zones:`, sorted.map(z => ({ id: z.id, name: z.name, model_path: z.model_path }))); + return sorted; + }, [currentZones]); + + return ( +
+
+
+

Зоны мониторинга

+ {onClose && ( + + )} +
+ {/* UI зон */} + {zonesError && ( +
+ Ошибка загрузки зон: {zonesError} +
+ )} + {zonesLoading && ( +
+ Загрузка зон... +
+ )} + {sortedZones.length > 0 && ( + <> + {sortedZones[0] && ( + + )} + {sortedZones.length > 1 && ( +
+ {sortedZones.slice(1).map((zone: Zone, idx: number) => ( + + ))} +
+ )} + + )} + {sortedZones.length === 0 && !zonesError && !zonesLoading && ( +
+
+ Зоны не найдены для выбранного объекта. Проверьте параметр objectId в API /api/get-zones. +
+
+ )} +
+
+ ); +}; + +export default Monitoring; \ No newline at end of file diff --git a/frontend/public/icons/Building3D.png b/frontend/public/icons/Building3D.png new file mode 100644 index 0000000..6c38ffb Binary files /dev/null and b/frontend/public/icons/Building3D.png differ diff --git a/frontend/public/icons/Floor3D.png b/frontend/public/icons/Floor3D.png new file mode 100644 index 0000000..1048092 Binary files /dev/null and b/frontend/public/icons/Floor3D.png differ