Переделана навигация к датчикам, добавлена работа поиска тултипов на модели, добавлен функционал перехода из дашборда и истории тревог к датчику с тревогой на 3д модели
This commit is contained in:
@@ -163,10 +163,12 @@ const NavigationPage: React.FC = () => {
|
|||||||
const urlObjectId = searchParams.get('objectId')
|
const urlObjectId = searchParams.get('objectId')
|
||||||
const urlObjectTitle = searchParams.get('objectTitle')
|
const urlObjectTitle = searchParams.get('objectTitle')
|
||||||
const urlModelPath = searchParams.get('modelPath')
|
const urlModelPath = searchParams.get('modelPath')
|
||||||
|
const urlFocusSensorId = searchParams.get('focusSensorId')
|
||||||
const objectId = currentObject.id || urlObjectId
|
const objectId = currentObject.id || urlObjectId
|
||||||
const objectTitle = currentObject.title || urlObjectTitle
|
const objectTitle = currentObject.title || urlObjectTitle
|
||||||
const [selectedModelPath, setSelectedModelPath] = useState<string>(urlModelPath || '')
|
const [selectedModelPath, setSelectedModelPath] = useState<string>(urlModelPath || '')
|
||||||
|
|
||||||
|
|
||||||
const handleModelLoaded = useCallback(() => {
|
const handleModelLoaded = useCallback(() => {
|
||||||
setIsModelReady(true)
|
setIsModelReady(true)
|
||||||
setModelError(null)
|
setModelError(null)
|
||||||
@@ -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 getStatusText = (status: string) => {
|
||||||
const s = (status || '').toLowerCase()
|
const s = (status || '').toLowerCase()
|
||||||
switch (s) {
|
switch (s) {
|
||||||
|
|||||||
615
frontend/app/(protected)/navigation/page.tsx — копия 3
Normal file
615
frontend/app/(protected)/navigation/page.tsx — копия 3
Normal file
@@ -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<ModelViewerProps>(() => import('../../../components/model/ModelViewer'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-[#0e111a]">
|
||||||
|
<div className="text-gray-300 animate-pulse">Загрузка 3D-модуля…</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
interface DetectorType {
|
||||||
|
detector_id: number
|
||||||
|
name: string
|
||||||
|
serial_number: string
|
||||||
|
object: string
|
||||||
|
status: string
|
||||||
|
checked: boolean
|
||||||
|
type: string
|
||||||
|
detector_type: string
|
||||||
|
location: string
|
||||||
|
floor: number
|
||||||
|
notifications: Array<{
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationType {
|
||||||
|
id: number
|
||||||
|
detector_id: number
|
||||||
|
detector_name: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
location: string
|
||||||
|
object: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertType {
|
||||||
|
id: number
|
||||||
|
detector_id: number
|
||||||
|
detector_name: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
location: string
|
||||||
|
object: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavigationPage: React.FC = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const {
|
||||||
|
currentObject,
|
||||||
|
setCurrentObject,
|
||||||
|
showMonitoring,
|
||||||
|
showFloorNavigation,
|
||||||
|
showNotifications,
|
||||||
|
showListOfDetectors,
|
||||||
|
showSensors,
|
||||||
|
selectedDetector,
|
||||||
|
showDetectorMenu,
|
||||||
|
selectedNotification,
|
||||||
|
showNotificationDetectorInfo,
|
||||||
|
selectedAlert,
|
||||||
|
showAlertMenu,
|
||||||
|
closeMonitoring,
|
||||||
|
closeFloorNavigation,
|
||||||
|
closeNotifications,
|
||||||
|
closeListOfDetectors,
|
||||||
|
closeSensors,
|
||||||
|
setSelectedDetector,
|
||||||
|
setShowDetectorMenu,
|
||||||
|
setSelectedNotification,
|
||||||
|
setShowNotificationDetectorInfo,
|
||||||
|
setSelectedAlert,
|
||||||
|
setShowAlertMenu,
|
||||||
|
showSensorHighlights,
|
||||||
|
toggleSensorHighlights
|
||||||
|
} = useNavigationStore()
|
||||||
|
|
||||||
|
const [detectorsData, setDetectorsData] = useState<{ detectors: Record<string, DetectorType> }>({ detectors: {} })
|
||||||
|
const [detectorsError, setDetectorsError] = useState<string | null>(null)
|
||||||
|
const [modelError, setModelError] = useState<string | null>(null)
|
||||||
|
const [isModelReady, setIsModelReady] = useState(false)
|
||||||
|
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
||||||
|
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
||||||
|
const sensorStatusMap = React.useMemo(() => {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
Object.values(detectorsData.detectors).forEach(d => {
|
||||||
|
if (d.serial_number && d.status) {
|
||||||
|
map[String(d.serial_number).trim()] = d.status
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors')
|
||||||
|
console.log('[NavigationPage] Sample sensor IDs in map:', Object.keys(map).slice(0, 5))
|
||||||
|
return map
|
||||||
|
}, [detectorsData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDetector === null && selectedAlert === null) {
|
||||||
|
setFocusedSensorId(null);
|
||||||
|
}
|
||||||
|
}, [selectedDetector, selectedAlert]);
|
||||||
|
|
||||||
|
// Управление выделением всех сенсоров при открытии/закрытии меню Sensors
|
||||||
|
// ИСПРАВЛЕНО: Подсветка датчиков остается включенной всегда, независимо от состояния панели Sensors
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady)
|
||||||
|
if (isModelReady) {
|
||||||
|
// Всегда включаем подсветку всех сенсоров когда модель готова
|
||||||
|
console.log('[NavigationPage] Setting highlightAllSensors to TRUE (always enabled)')
|
||||||
|
setHighlightAllSensors(true)
|
||||||
|
// Сбрасываем фокус только если панель Sensors закрыта
|
||||||
|
if (!showSensors) {
|
||||||
|
setFocusedSensorId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showSensors, isModelReady])
|
||||||
|
|
||||||
|
// Дополнительный эффект для задержки выделения сенсоров при открытии меню
|
||||||
|
// ИСПРАВЛЕНО: Задержка применяется только при открытии панели Sensors
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSensors && isModelReady) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
console.log('[NavigationPage] Delayed highlightAllSensors to TRUE')
|
||||||
|
setHighlightAllSensors(true)
|
||||||
|
}, 500) // Задержка 500мс для полной инициализации модели
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [showSensors, isModelReady])
|
||||||
|
|
||||||
|
const urlObjectId = searchParams.get('objectId')
|
||||||
|
const urlObjectTitle = searchParams.get('objectTitle')
|
||||||
|
const urlModelPath = searchParams.get('modelPath')
|
||||||
|
const objectId = currentObject.id || urlObjectId
|
||||||
|
const objectTitle = currentObject.title || urlObjectTitle
|
||||||
|
const [selectedModelPath, setSelectedModelPath] = useState<string>(urlModelPath || '')
|
||||||
|
|
||||||
|
const handleModelLoaded = useCallback(() => {
|
||||||
|
setIsModelReady(true)
|
||||||
|
setModelError(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleModelError = useCallback((error: string) => {
|
||||||
|
console.error('[NavigationPage] Model loading error:', error)
|
||||||
|
setModelError(error)
|
||||||
|
setIsModelReady(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedModelPath) {
|
||||||
|
setIsModelReady(false);
|
||||||
|
setModelError(null);
|
||||||
|
// Сохраняем выбранную модель в URL для восстановления при возврате
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set('modelPath', selectedModelPath);
|
||||||
|
window.history.replaceState(null, '', `?${params.toString()}`);
|
||||||
|
}
|
||||||
|
}, [selectedModelPath, searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
||||||
|
setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined)
|
||||||
|
}
|
||||||
|
}, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject])
|
||||||
|
|
||||||
|
// Восстановление выбранной модели из URL при загрузке страницы
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlModelPath && !selectedModelPath) {
|
||||||
|
setSelectedModelPath(urlModelPath);
|
||||||
|
}
|
||||||
|
}, [urlModelPath, selectedModelPath])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDetectors = async () => {
|
||||||
|
try {
|
||||||
|
setDetectorsError(null)
|
||||||
|
const res = await fetch('/api/get-detectors', { cache: 'no-store' })
|
||||||
|
const text = await res.text()
|
||||||
|
let payload: any
|
||||||
|
try { payload = JSON.parse(text) } catch { payload = text }
|
||||||
|
console.log('[NavigationPage] GET /api/get-detectors', { status: res.status, payload })
|
||||||
|
if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов'))
|
||||||
|
const data = payload?.data ?? payload
|
||||||
|
const detectors = (data?.detectors ?? {}) as Record<string, DetectorType>
|
||||||
|
console.log('[NavigationPage] Received detectors count:', Object.keys(detectors).length)
|
||||||
|
console.log('[NavigationPage] Sample detector keys:', Object.keys(detectors).slice(0, 5))
|
||||||
|
setDetectorsData({ detectors })
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Ошибка загрузки детекторов:', e)
|
||||||
|
setDetectorsError(e?.message || 'Ошибка при загрузке детекторов')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadDetectors()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleBackClick = () => {
|
||||||
|
router.push('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDetectorMenuClick = (detector: DetectorType) => {
|
||||||
|
// Для тестов. Выбор детектора.
|
||||||
|
console.log('[NavigationPage] Selected detector click:', {
|
||||||
|
detector_id: detector.detector_id,
|
||||||
|
name: detector.name,
|
||||||
|
serial_number: detector.serial_number,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Проверяем, что детектор имеет необходимые данные
|
||||||
|
if (!detector || !detector.detector_id || !detector.serial_number) {
|
||||||
|
console.warn('[NavigationPage] Invalid detector data, skipping menu display:', detector)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) {
|
||||||
|
closeDetectorMenu()
|
||||||
|
} else {
|
||||||
|
setSelectedDetector(detector)
|
||||||
|
setShowDetectorMenu(true)
|
||||||
|
setFocusedSensorId(detector.serial_number)
|
||||||
|
setShowAlertMenu(false)
|
||||||
|
setSelectedAlert(null)
|
||||||
|
// При открытии меню детектора - сбрасываем множественное выделение
|
||||||
|
setHighlightAllSensors(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDetectorMenu = () => {
|
||||||
|
setShowDetectorMenu(false)
|
||||||
|
setSelectedDetector(null)
|
||||||
|
setFocusedSensorId(null)
|
||||||
|
setSelectedAlert(null)
|
||||||
|
// При закрытии меню детектора - выделяем все сенсоры снова
|
||||||
|
setHighlightAllSensors(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNotificationClick = (notification: NotificationType) => {
|
||||||
|
if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) {
|
||||||
|
setShowNotificationDetectorInfo(false)
|
||||||
|
setSelectedNotification(null)
|
||||||
|
} else {
|
||||||
|
setSelectedNotification(notification)
|
||||||
|
setShowNotificationDetectorInfo(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeNotificationDetectorInfo = () => {
|
||||||
|
setShowNotificationDetectorInfo(false)
|
||||||
|
setSelectedNotification(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAlertMenu = () => {
|
||||||
|
setShowAlertMenu(false)
|
||||||
|
setSelectedAlert(null)
|
||||||
|
setFocusedSensorId(null)
|
||||||
|
setSelectedDetector(null)
|
||||||
|
// При закрытии меню алерта - выделяем все сенсоры снова
|
||||||
|
setHighlightAllSensors(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAlertClick = (alert: AlertType) => {
|
||||||
|
console.log('[NavigationPage] Alert clicked, focusing on detector in 3D scene:', alert)
|
||||||
|
|
||||||
|
const detector = Object.values(detectorsData.detectors).find(
|
||||||
|
d => d.detector_id === alert.detector_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (detector) {
|
||||||
|
if (selectedAlert?.id === alert.id && showAlertMenu) {
|
||||||
|
closeAlertMenu()
|
||||||
|
} else {
|
||||||
|
setSelectedAlert(alert)
|
||||||
|
setShowAlertMenu(true)
|
||||||
|
setFocusedSensorId(detector.serial_number)
|
||||||
|
setShowDetectorMenu(false)
|
||||||
|
setSelectedDetector(null)
|
||||||
|
// При открытии меню алерта - сбрасываем множественное выделение
|
||||||
|
setHighlightAllSensors(false)
|
||||||
|
console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSensorSelection = (serialNumber: string | null) => {
|
||||||
|
if (serialNumber === null) {
|
||||||
|
setFocusedSensorId(null);
|
||||||
|
closeDetectorMenu();
|
||||||
|
closeAlertMenu();
|
||||||
|
// If we're in Sensors menu and no sensor is selected, highlight all sensors
|
||||||
|
if (showSensors) {
|
||||||
|
setHighlightAllSensors(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusedSensorId === serialNumber) {
|
||||||
|
setFocusedSensorId(null);
|
||||||
|
closeDetectorMenu();
|
||||||
|
closeAlertMenu();
|
||||||
|
// If we're in Sensors menu and deselected the current sensor, highlight all sensors
|
||||||
|
if (showSensors) {
|
||||||
|
setHighlightAllSensors(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// При выборе конкретного сенсора - сбрасываем множественное выделение
|
||||||
|
setHighlightAllSensors(false)
|
||||||
|
|
||||||
|
const detector = Object.values(detectorsData?.detectors || {}).find(
|
||||||
|
(d) => d.serial_number === serialNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (detector) {
|
||||||
|
if (showFloorNavigation || showListOfDetectors) {
|
||||||
|
handleDetectorMenuClick(detector);
|
||||||
|
} else if (detector.notifications && detector.notifications.length > 0) {
|
||||||
|
const sortedNotifications = [...detector.notifications].sort((a, b) => {
|
||||||
|
const priorityOrder: { [key: string]: number } = { critical: 0, warning: 1, info: 2 };
|
||||||
|
return priorityOrder[a.priority.toLowerCase()] - priorityOrder[b.priority.toLowerCase()];
|
||||||
|
});
|
||||||
|
const notification = sortedNotifications[0];
|
||||||
|
const alert: AlertType = {
|
||||||
|
...notification,
|
||||||
|
detector_id: detector.detector_id,
|
||||||
|
detector_name: detector.name,
|
||||||
|
location: detector.location,
|
||||||
|
object: detector.object,
|
||||||
|
status: detector.status,
|
||||||
|
type: notification.type || 'info',
|
||||||
|
};
|
||||||
|
handleAlertClick(alert);
|
||||||
|
} else {
|
||||||
|
handleDetectorMenuClick(detector);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFocusedSensorId(null);
|
||||||
|
closeDetectorMenu();
|
||||||
|
closeAlertMenu();
|
||||||
|
// If we're in Sensors menu and no valid detector found, highlight all sensors
|
||||||
|
if (showSensors) {
|
||||||
|
setHighlightAllSensors(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
const s = (status || '').toLowerCase()
|
||||||
|
switch (s) {
|
||||||
|
case statusColors.STATUS_COLOR_CRITICAL:
|
||||||
|
case 'critical':
|
||||||
|
return 'Критический'
|
||||||
|
case statusColors.STATUS_COLOR_WARNING:
|
||||||
|
case 'warning':
|
||||||
|
return 'Предупреждение'
|
||||||
|
case statusColors.STATUS_COLOR_NORMAL:
|
||||||
|
case 'normal':
|
||||||
|
return 'Норма'
|
||||||
|
default:
|
||||||
|
return 'Неизвестно'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-20">
|
||||||
|
<Sidebar
|
||||||
|
activeItem={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex-1 flex flex-col">
|
||||||
|
|
||||||
|
{showMonitoring && (
|
||||||
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<Monitoring
|
||||||
|
onClose={closeMonitoring}
|
||||||
|
onSelectModel={(path) => {
|
||||||
|
console.log('[NavigationPage] Model selected:', path);
|
||||||
|
setSelectedModelPath(path)
|
||||||
|
setModelError(null)
|
||||||
|
setIsModelReady(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFloorNavigation && (
|
||||||
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<FloorNavigation
|
||||||
|
objectId={objectId || undefined}
|
||||||
|
detectorsData={detectorsData}
|
||||||
|
onDetectorMenuClick={handleDetectorMenuClick}
|
||||||
|
onClose={closeFloorNavigation}
|
||||||
|
is3DReady={isModelReady && !modelError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNotifications && (
|
||||||
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<Notifications
|
||||||
|
objectId={objectId || undefined}
|
||||||
|
detectorsData={detectorsData}
|
||||||
|
onNotificationClick={handleNotificationClick}
|
||||||
|
onClose={closeNotifications}
|
||||||
|
/>
|
||||||
|
{detectorsError && (
|
||||||
|
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showListOfDetectors && (
|
||||||
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<ListOfDetectors
|
||||||
|
objectId={objectId || undefined}
|
||||||
|
detectorsData={detectorsData}
|
||||||
|
onDetectorMenuClick={handleDetectorMenuClick}
|
||||||
|
onClose={closeListOfDetectors}
|
||||||
|
is3DReady={selectedModelPath ? !modelError : false}
|
||||||
|
/>
|
||||||
|
{detectorsError && (
|
||||||
|
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSensors && (
|
||||||
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<Sensors
|
||||||
|
objectId={objectId || undefined}
|
||||||
|
detectorsData={detectorsData}
|
||||||
|
onAlertClick={handleAlertClick}
|
||||||
|
onClose={closeSensors}
|
||||||
|
is3DReady={selectedModelPath ? !modelError : false}
|
||||||
|
/>
|
||||||
|
{detectorsError && (
|
||||||
|
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNotifications && showNotificationDetectorInfo && selectedNotification && (() => {
|
||||||
|
const detectorData = Object.values(detectorsData.detectors).find(
|
||||||
|
detector => detector.detector_id === selectedNotification.detector_id
|
||||||
|
);
|
||||||
|
return detectorData ? (
|
||||||
|
<div className="absolute left-[500px] top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<NotificationDetectorInfo
|
||||||
|
detectorData={detectorData}
|
||||||
|
onClose={closeNotificationDetectorInfo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{showFloorNavigation && showDetectorMenu && selectedDetector && (
|
||||||
|
null
|
||||||
|
)}
|
||||||
|
|
||||||
|
<header className="bg-[#161824] border-b border-gray-700 px-6 h-[73px] flex items-center">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={handleBackClick}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
|
aria-label="Назад к дашборду"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<nav className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-gray-400">Дашборд</span>
|
||||||
|
<span className="text-gray-600">{'/'}</span>
|
||||||
|
<span className="text-white">{objectTitle || 'Объект'}</span>
|
||||||
|
<span className="text-gray-600">{'/'}</span>
|
||||||
|
<span className="text-white">Навигация</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<div className="h-full">
|
||||||
|
{modelError ? (
|
||||||
|
<>
|
||||||
|
{console.log('[NavigationPage] Rendering error message, modelError:', modelError)}
|
||||||
|
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
||||||
|
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
||||||
|
<div className="text-red-400 text-lg font-semibold mb-4">
|
||||||
|
Ошибка загрузки 3D модели
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-300 mb-4">
|
||||||
|
{modelError}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Используйте навигацию по этажам для просмотра детекторов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : !selectedModelPath ? (
|
||||||
|
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
||||||
|
<div className="text-center p-8 flex flex-col items-center">
|
||||||
|
<Image
|
||||||
|
src="/icons/logo.png"
|
||||||
|
alt="AerBIM HT Monitor"
|
||||||
|
width={300}
|
||||||
|
height={41}
|
||||||
|
className="mb-6"
|
||||||
|
/>
|
||||||
|
<div className="text-gray-300 text-lg">
|
||||||
|
Выберите модель для отображения
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ModelViewer
|
||||||
|
key={selectedModelPath || 'no-model'}
|
||||||
|
modelPath={selectedModelPath}
|
||||||
|
onSelectModel={(path) => {
|
||||||
|
console.log('[NavigationPage] Model selected:', path);
|
||||||
|
setSelectedModelPath(path)
|
||||||
|
setModelError(null)
|
||||||
|
setIsModelReady(false)
|
||||||
|
}}
|
||||||
|
onModelLoaded={handleModelLoaded}
|
||||||
|
onError={handleModelError}
|
||||||
|
activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null}
|
||||||
|
focusSensorId={focusedSensorId}
|
||||||
|
highlightAllSensors={showSensorHighlights && highlightAllSensors}
|
||||||
|
sensorStatusMap={sensorStatusMap}
|
||||||
|
isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors}
|
||||||
|
onSensorPick={handleSensorSelection}
|
||||||
|
renderOverlay={({ anchor }) => (
|
||||||
|
<>
|
||||||
|
{selectedAlert && showAlertMenu && anchor ? (
|
||||||
|
<AlertMenu
|
||||||
|
alert={selectedAlert}
|
||||||
|
isOpen={true}
|
||||||
|
onClose={closeAlertMenu}
|
||||||
|
getStatusText={getStatusText}
|
||||||
|
compact={true}
|
||||||
|
anchor={anchor}
|
||||||
|
/>
|
||||||
|
) : selectedDetector && showDetectorMenu && anchor ? (
|
||||||
|
<DetectorMenu
|
||||||
|
detector={selectedDetector}
|
||||||
|
isOpen={true}
|
||||||
|
onClose={closeDetectorMenu}
|
||||||
|
getStatusText={getStatusText}
|
||||||
|
compact={true}
|
||||||
|
anchor={anchor}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NavigationPage
|
||||||
@@ -117,6 +117,9 @@ export interface NavigationStore {
|
|||||||
isOnNavigationPage: () => boolean
|
isOnNavigationPage: () => boolean
|
||||||
getCurrentRoute: () => string | null
|
getCurrentRoute: () => string | null
|
||||||
getActiveSidebarItem: () => number
|
getActiveSidebarItem: () => number
|
||||||
|
|
||||||
|
// Навигация к датчику на 3D-модели
|
||||||
|
navigateToSensor: (sensorSerialNumber: string, floor: number | null, viewType: 'building' | 'floor') => Promise<string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
const useNavigationStore = create<NavigationStore>()(
|
const useNavigationStore = create<NavigationStore>()(
|
||||||
@@ -368,6 +371,105 @@ const useNavigationStore = create<NavigationStore>()(
|
|||||||
if (showListOfDetectors) return 7 // Список датчиков
|
if (showListOfDetectors) return 7 // Список датчиков
|
||||||
if (showSensors) return 8 // Сенсоры
|
if (showSensors) return 8 // Сенсоры
|
||||||
return 2 // Навигация (базовая)
|
return 2 // Навигация (базовая)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Навигация к датчику на 3D-модели
|
||||||
|
navigateToSensor: async (sensorSerialNumber: string, floor: number | null, viewType: 'building' | 'floor') => {
|
||||||
|
const { currentObject, loadZones } = get()
|
||||||
|
|
||||||
|
if (!currentObject.id) {
|
||||||
|
console.error('[navigateToSensor] No current object selected')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем зоны для объекта (из кэша или API)
|
||||||
|
await loadZones(currentObject.id)
|
||||||
|
|
||||||
|
const { currentZones } = get()
|
||||||
|
|
||||||
|
if (!currentZones || currentZones.length === 0) {
|
||||||
|
console.error('[navigateToSensor] No zones available for object', currentObject.id)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetZone: Zone | undefined
|
||||||
|
|
||||||
|
if (viewType === 'building') {
|
||||||
|
// Для общего вида здания - ищем самую верхнюю зону (первую в списке)
|
||||||
|
targetZone = currentZones[0]
|
||||||
|
console.log('[navigateToSensor] Building view - selected first zone:', targetZone?.name)
|
||||||
|
} else if (viewType === 'floor') {
|
||||||
|
// Для вида на этаже - ищем зону, где есть этот датчик
|
||||||
|
// Сначала проверяем зоны с sensors массивом
|
||||||
|
for (const zone of currentZones) {
|
||||||
|
if (zone.sensors && Array.isArray(zone.sensors)) {
|
||||||
|
const hasSensor = zone.sensors.some(s =>
|
||||||
|
s.serial_number === sensorSerialNumber ||
|
||||||
|
s.name === sensorSerialNumber
|
||||||
|
)
|
||||||
|
if (hasSensor) {
|
||||||
|
targetZone = zone
|
||||||
|
console.log('[navigateToSensor] Found sensor in zone:', zone.name, 'sensors:', zone.sensors.length)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если не нашли по sensors, пробуем по floor
|
||||||
|
if (!targetZone && floor !== null) {
|
||||||
|
// Ищем зоны с соответствующим floor (кроме общего вида)
|
||||||
|
const floorZones = currentZones.filter(z =>
|
||||||
|
z.floor === floor &&
|
||||||
|
z.order !== 0 &&
|
||||||
|
z.model_path
|
||||||
|
)
|
||||||
|
|
||||||
|
if (floorZones.length > 0) {
|
||||||
|
targetZone = floorZones[0]
|
||||||
|
console.log('[navigateToSensor] Found zone by floor:', targetZone.name, 'floor:', floor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback на общий вид, если не нашли зону этажа
|
||||||
|
if (!targetZone) {
|
||||||
|
console.warn(`[navigateToSensor] No zone found with sensor ${sensorSerialNumber} or floor ${floor}, falling back to building view`)
|
||||||
|
targetZone = currentZones[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetZone || !targetZone.model_path) {
|
||||||
|
console.error('[navigateToSensor] No valid zone with model_path found')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем состояние для навигации
|
||||||
|
set({
|
||||||
|
currentModelPath: targetZone.model_path,
|
||||||
|
// Открываем Зоны контроля (Monitoring) - она автоматически закроется после загрузки модели
|
||||||
|
showMonitoring: true,
|
||||||
|
// Закрываем остальные меню
|
||||||
|
showFloorNavigation: false,
|
||||||
|
showNotifications: false,
|
||||||
|
showListOfDetectors: false,
|
||||||
|
// НЕ закрываем showSensors - оставляем как есть для подсветки датчиков
|
||||||
|
// showSensors: false, <- Убрали!
|
||||||
|
showDetectorMenu: false,
|
||||||
|
showAlertMenu: false,
|
||||||
|
selectedDetector: null,
|
||||||
|
selectedAlert: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[navigateToSensor] Navigation prepared:', {
|
||||||
|
sensorSerialNumber,
|
||||||
|
floor,
|
||||||
|
viewType,
|
||||||
|
modelPath: targetZone.model_path,
|
||||||
|
zoneName: targetZone.name,
|
||||||
|
zoneId: targetZone.id
|
||||||
|
})
|
||||||
|
|
||||||
|
// Возвращаем serial_number для установки focusedSensorId в компоненте
|
||||||
|
return sensorSerialNumber
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|||||||
380
frontend/app/store/navigationStore — копия 2.ts
Normal file
380
frontend/app/store/navigationStore — копия 2.ts
Normal file
@@ -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<string, Zone[]>
|
||||||
|
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<void>
|
||||||
|
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<NavigationStore>()(
|
||||||
|
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<string, unknown>
|
||||||
|
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
|
||||||
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useState, useMemo } from 'react'
|
import React, { useState, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useNavigationStore from '../../app/store/navigationStore'
|
||||||
import * as statusColors from '../../lib/statusColors'
|
import * as statusColors from '../../lib/statusColors'
|
||||||
|
|
||||||
interface AlertItem {
|
interface AlertItem {
|
||||||
@@ -12,6 +14,8 @@ interface AlertItem {
|
|||||||
detector_name?: string
|
detector_name?: string
|
||||||
location?: string
|
location?: string
|
||||||
object?: string
|
object?: string
|
||||||
|
serial_number?: string
|
||||||
|
floor?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AlertsListProps {
|
interface AlertsListProps {
|
||||||
@@ -21,6 +25,8 @@ interface AlertsListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, initialSearchTerm = '' }) => {
|
const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, initialSearchTerm = '' }) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const { navigateToSensor } = useNavigationStore()
|
||||||
const [searchTerm, setSearchTerm] = useState(initialSearchTerm)
|
const [searchTerm, setSearchTerm] = useState(initialSearchTerm)
|
||||||
|
|
||||||
const filteredAlerts = useMemo(() => {
|
const filteredAlerts = useMemo(() => {
|
||||||
@@ -46,6 +52,26 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleGoTo3D = async (alert: AlertItem, viewType: 'building' | 'floor') => {
|
||||||
|
// Используем доступные идентификаторы датчика
|
||||||
|
const sensorId = alert.serial_number || alert.detector_name || alert.detector_id
|
||||||
|
|
||||||
|
if (!sensorId) {
|
||||||
|
console.warn('[AlertsList] Alert missing sensor identifier:', alert)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensorSerialNumber = await navigateToSensor(
|
||||||
|
sensorId,
|
||||||
|
alert.floor || null,
|
||||||
|
viewType
|
||||||
|
)
|
||||||
|
|
||||||
|
if (sensorSerialNumber) {
|
||||||
|
router.push(`/navigation?focusSensorId=${encodeURIComponent(sensorSerialNumber)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Поиск */}
|
{/* Поиск */}
|
||||||
@@ -81,6 +107,7 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
|
|||||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Приоритет</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Приоритет</th>
|
||||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Подтверждено</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Подтверждено</th>
|
||||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Время</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Время</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-center text-white text-sm py-3">3D Вид</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -141,11 +168,33 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
|
|||||||
<td style={interRegularStyle} className="py-4 text-sm text-gray-300">
|
<td style={interRegularStyle} className="py-4 text-sm text-gray-300">
|
||||||
{new Date(item.timestamp).toLocaleString('ru-RU')}
|
{new Date(item.timestamp).toLocaleString('ru-RU')}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleGoTo3D(item, 'building')}
|
||||||
|
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
|
||||||
|
title="Показать на общей модели"
|
||||||
|
>
|
||||||
|
<img src="/icons/Building3D.png" alt="Здание" className="w-5 h-5 opacity-70 group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleGoTo3D(item, 'floor')}
|
||||||
|
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
|
||||||
|
title="Показать на этаже"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/icons/Floor3D.png"
|
||||||
|
alt="Этаж"
|
||||||
|
className="w-5 h-5 opacity-70 group-hover:opacity-100"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{filteredAlerts.length === 0 && (
|
{filteredAlerts.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="py-8 text-center text-gray-400">
|
<td colSpan={8} className="py-8 text-center text-gray-400">
|
||||||
Записей не найдено
|
Записей не найдено
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
161
frontend/components/alerts/AlertsList.tsx — копия
Normal file
161
frontend/components/alerts/AlertsList.tsx — копия
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import React, { useState, useMemo } from 'react'
|
||||||
|
import * as statusColors from '../../lib/statusColors'
|
||||||
|
|
||||||
|
interface AlertItem {
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
detector_id?: string
|
||||||
|
detector_name?: string
|
||||||
|
location?: string
|
||||||
|
object?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertsListProps {
|
||||||
|
alerts: AlertItem[]
|
||||||
|
onAcknowledgeToggle: (alertId: number) => void
|
||||||
|
initialSearchTerm?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, initialSearchTerm = '' }) => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState(initialSearchTerm)
|
||||||
|
|
||||||
|
const filteredAlerts = useMemo(() => {
|
||||||
|
return alerts.filter(alert => {
|
||||||
|
const matchesSearch = searchTerm === '' || alert.detector_id?.toString() === searchTerm
|
||||||
|
return matchesSearch
|
||||||
|
})
|
||||||
|
}, [alerts, searchTerm])
|
||||||
|
|
||||||
|
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
|
||||||
|
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
|
||||||
|
|
||||||
|
const getStatusColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'critical':
|
||||||
|
return statusColors.STATUS_COLOR_CRITICAL
|
||||||
|
case 'warning':
|
||||||
|
return statusColors.STATUS_COLOR_WARNING
|
||||||
|
case 'info':
|
||||||
|
return statusColors.STATUS_COLOR_NORMAL
|
||||||
|
default:
|
||||||
|
return statusColors.STATUS_COLOR_UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Поиск */}
|
||||||
|
<div className="flex items-center justify-end gap-4 mb-6">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по ID детектора..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64"
|
||||||
|
/>
|
||||||
|
<svg className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Таблица алертов */}
|
||||||
|
<div className="bg-[#161824] rounded-[20px] p-6">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 style={interSemiboldStyle} className="text-xl text-white">История тревог</h2>
|
||||||
|
<span style={interRegularStyle} className="text-sm text-gray-400">Всего: {filteredAlerts.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-700">
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Статус</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Местоположение</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Приоритет</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Подтверждено</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Время</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredAlerts.map((item) => (
|
||||||
|
<tr key={item.id} className="border-b border-gray-700 hover:bg-gray-800/50 transition-colors">
|
||||||
|
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
||||||
|
<div>{item.detector_name || 'Детектор'}</div>
|
||||||
|
{item.detector_id ? (
|
||||||
|
<div className="text-gray-400">ID: {item.detector_id}</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: getStatusColor(item.type) }}
|
||||||
|
></div>
|
||||||
|
<span style={interRegularStyle} className="text-sm text-gray-300">
|
||||||
|
{item.type === 'critical' ? 'Критический' : item.type === 'warning' ? 'Предупреждение' : 'Информация'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
||||||
|
{item.message}
|
||||||
|
</td>
|
||||||
|
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
||||||
|
{item.location || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
item.priority === 'high'
|
||||||
|
? statusColors.STATUS_COLOR_CRITICAL
|
||||||
|
: item.priority === 'medium'
|
||||||
|
? statusColors.STATUS_COLOR_WARNING
|
||||||
|
: statusColors.STATUS_COLOR_NORMAL,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.priority === 'high' ? 'Высокий' : item.priority === 'medium' ? 'Средний' : 'Низкий'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<span style={interRegularStyle} className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs ${
|
||||||
|
item.acknowledged ? 'bg-green-600/20 text-green-300 ring-1 ring-green-600/40' : 'bg-red-600/20 text-red-300 ring-1 ring-red-600/40'
|
||||||
|
}`}>
|
||||||
|
{item.acknowledged ? 'Да' : 'Нет'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onAcknowledgeToggle(item.id)}
|
||||||
|
style={interRegularStyle}
|
||||||
|
className="ml-2 inline-flex items-center px-2 py-1 rounded text-xs bg-[#2a2e3e] text-white hover:bg-[#353a4d]"
|
||||||
|
>
|
||||||
|
{item.acknowledged ? 'Снять' : 'Подтвердить'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td style={interRegularStyle} className="py-4 text-sm text-gray-300">
|
||||||
|
{new Date(item.timestamp).toLocaleString('ru-RU')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{filteredAlerts.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="py-8 text-center text-gray-400">
|
||||||
|
Записей не найдено
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlertsList
|
||||||
@@ -38,10 +38,37 @@ interface DetectorListProps {
|
|||||||
onDetectorSelect: (detectorId: number, selected: boolean) => void
|
onDetectorSelect: (detectorId: number, selected: boolean) => void
|
||||||
initialSearchTerm?: string
|
initialSearchTerm?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Функция для генерации умного диапазона страниц
|
||||||
|
function getPaginationRange(currentPage: number, totalPages: number): (number | string)[] {
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
// Если страниц мало - показываем все
|
||||||
|
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Всегда показываем первые 3 и последние 3
|
||||||
|
const start = [1, 2, 3]
|
||||||
|
const end = [totalPages - 2, totalPages - 1, totalPages]
|
||||||
|
|
||||||
|
if (currentPage <= 4) {
|
||||||
|
// Начало: 1 2 3 4 5 ... 11 12 13
|
||||||
|
return [1, 2, 3, 4, 5, '...', ...end]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage >= totalPages - 3) {
|
||||||
|
// Конец: 1 2 3 ... 9 10 11 12 13
|
||||||
|
return [...start, '...', totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Середина: 1 2 3 ... 6 7 8 ... 11 12 13
|
||||||
|
return [...start, '...', currentPage - 1, currentPage, currentPage + 1, '...', ...end]
|
||||||
|
}
|
||||||
|
|
||||||
const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors, onDetectorSelect, initialSearchTerm = '' }) => {
|
const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors, onDetectorSelect, initialSearchTerm = '' }) => {
|
||||||
const [detectors, setDetectors] = useState<Detector[]>([])
|
const [detectors, setDetectors] = useState<Detector[]>([])
|
||||||
const [searchTerm, setSearchTerm] = useState<string>(initialSearchTerm)
|
const [searchTerm, setSearchTerm] = useState<string>(initialSearchTerm)
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const itemsPerPage = 20
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDetectors = async () => {
|
const loadDetectors = async () => {
|
||||||
@@ -76,6 +103,24 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
|||||||
|
|
||||||
return matchesSearch
|
return matchesSearch
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Сброс на первую страницу при изменении поиска
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1)
|
||||||
|
}, [searchTerm])
|
||||||
|
|
||||||
|
// Пагинация
|
||||||
|
const totalPages = Math.ceil(filteredDetectors.length / itemsPerPage)
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage
|
||||||
|
const endIndex = startIndex + itemsPerPage
|
||||||
|
const currentDetectors = filteredDetectors.slice(startIndex, endIndex)
|
||||||
|
const paginationRange = getPaginationRange(currentPage, totalPages)
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page)
|
||||||
|
// Скролл наверх таблицы
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -109,16 +154,16 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
|||||||
<th className="text-left text-white font-medium py-3 w-12">
|
<th className="text-left text-white font-medium py-3 w-12">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedDetectors.length === filteredDetectors.length && filteredDetectors.length > 0}
|
checked={selectedDetectors.length === currentDetectors.length && currentDetectors.length > 0}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
filteredDetectors.forEach(detector => {
|
currentDetectors.forEach(detector => {
|
||||||
if (!selectedDetectors.includes(detector.detector_id)) {
|
if (!selectedDetectors.includes(detector.detector_id)) {
|
||||||
onDetectorSelect(detector.detector_id, true)
|
onDetectorSelect(detector.detector_id, true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
filteredDetectors.forEach(detector => {
|
currentDetectors.forEach(detector => {
|
||||||
if (selectedDetectors.includes(detector.detector_id)) {
|
if (selectedDetectors.includes(detector.detector_id)) {
|
||||||
onDetectorSelect(detector.detector_id, false)
|
onDetectorSelect(detector.detector_id, false)
|
||||||
}
|
}
|
||||||
@@ -135,7 +180,7 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredDetectors.map((detector) => {
|
{currentDetectors.map((detector) => {
|
||||||
const isSelected = selectedDetectors.includes(detector.detector_id)
|
const isSelected = selectedDetectors.includes(detector.detector_id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -183,9 +228,76 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Пагинация */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-6 flex flex-col items-center gap-4">
|
||||||
|
{/* Кнопки пагинации */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Кнопка "Предыдущая" */}
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
currentPage === 1
|
||||||
|
? 'text-gray-600 cursor-not-allowed'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-[#1E293B]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
← Предыдущая
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Номера страниц */}
|
||||||
|
{paginationRange.map((page, index) => {
|
||||||
|
if (page === '...') {
|
||||||
|
return (
|
||||||
|
<span key={`ellipsis-${index}`} className="px-3 py-2 text-gray-500">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNumber = page as number
|
||||||
|
const isActive = pageNumber === currentPage
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pageNumber}
|
||||||
|
onClick={() => handlePageChange(pageNumber)}
|
||||||
|
className={`min-w-[40px] px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-[#1E293B]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Кнопка "Следующая" */}
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
currentPage === totalPages
|
||||||
|
? 'text-gray-600 cursor-not-allowed'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-[#1E293B]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Следующая →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Счётчик */}
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Показано {startIndex + 1}-{Math.min(endIndex, filteredDetectors.length)} из {filteredDetectors.length} датчиков
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Статы детекторров*/}
|
{/* Статы детекторов */}
|
||||||
<div className="mt-6 grid grid-cols-4 gap-4">
|
<div className="mt-6 grid grid-cols-4 gap-4">
|
||||||
<div className="bg-[#161824] p-4 rounded-lg">
|
<div className="bg-[#161824] p-4 rounded-lg">
|
||||||
<div className="text-2xl font-bold text-white">{filteredDetectors.length}</div>
|
<div className="text-2xl font-bold text-white">{filteredDetectors.length}</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { aggregateChartDataByDays } from '../../lib/chartDataAggregator'
|
|||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications } = useNavigationStore()
|
const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications, navigateToSensor } = useNavigationStore()
|
||||||
const objectTitle = currentObject?.title
|
const objectTitle = currentObject?.title
|
||||||
|
|
||||||
const [dashboardAlerts, setDashboardAlerts] = useState<any[]>([])
|
const [dashboardAlerts, setDashboardAlerts] = useState<any[]>([])
|
||||||
@@ -119,6 +119,27 @@ const Dashboard: React.FC = () => {
|
|||||||
setCurrentSubmenu(null)
|
setCurrentSubmenu(null)
|
||||||
router.push('/navigation')
|
router.push('/navigation')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleGoTo3D = async (alert: any, viewType: 'building' | 'floor') => {
|
||||||
|
// Используем alert.name как идентификатор датчика (например, "GA-11")
|
||||||
|
const sensorId = alert.serial_number || alert.name
|
||||||
|
|
||||||
|
if (!sensorId) {
|
||||||
|
console.warn('[Dashboard] Alert missing sensor identifier:', alert)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensorSerialNumber = await navigateToSensor(
|
||||||
|
sensorId,
|
||||||
|
alert.floor || null,
|
||||||
|
viewType
|
||||||
|
)
|
||||||
|
|
||||||
|
if (sensorSerialNumber) {
|
||||||
|
// Переходим на страницу навигации с параметром focusSensorId
|
||||||
|
router.push(`/navigation?focusSensorId=${encodeURIComponent(sensorSerialNumber)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSensorTypeChange = (sensorType: string) => {
|
const handleSensorTypeChange = (sensorType: string) => {
|
||||||
setSelectedSensorType(sensorType)
|
setSelectedSensorType(sensorType)
|
||||||
@@ -266,6 +287,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Серьезность</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Серьезность</th>
|
||||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Дата</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Дата</th>
|
||||||
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Решен</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Решен</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-center text-white text-sm py-3">3D Вид</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -287,6 +309,28 @@ const Dashboard: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleGoTo3D(alert, 'building')}
|
||||||
|
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
|
||||||
|
title="Показать на общей модели"
|
||||||
|
>
|
||||||
|
<img src="/icons/Building3D.png" alt="Здание" className="w-5 h-5 opacity-70 group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleGoTo3D(alert, 'floor')}
|
||||||
|
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
|
||||||
|
title="Показать на этаже"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/icons/Floor3D.png"
|
||||||
|
alt="Этаж"
|
||||||
|
className="w-5 h-5 opacity-70 group-hover:opacity-100"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
324
frontend/components/dashboard/Dashboard.tsx — копия 2
Normal file
324
frontend/components/dashboard/Dashboard.tsx — копия 2
Normal file
@@ -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<any[]>([])
|
||||||
|
const [rawChartData, setRawChartData] = useState<{ timestamp: string; value: number }[]>([])
|
||||||
|
const [sensorTypes] = useState<Array<{code: string, name: string}>>([
|
||||||
|
{ code: '', name: 'Все датчики' },
|
||||||
|
{ code: 'GA', name: 'Инклинометр' },
|
||||||
|
{ code: 'PE', name: 'Танзометр' },
|
||||||
|
{ code: 'GLE', name: 'Гидроуровень' }
|
||||||
|
])
|
||||||
|
const [selectedSensorType, setSelectedSensorType] = useState<string>('')
|
||||||
|
const [selectedChartPeriod, setSelectedChartPeriod] = useState<string>('168')
|
||||||
|
const [selectedTablePeriod, setSelectedTablePeriod] = useState<string>('168')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDashboard = async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.append('time_period', selectedChartPeriod)
|
||||||
|
|
||||||
|
const res = await fetch(`/api/get-dashboard?${params.toString()}`, { cache: 'no-store' })
|
||||||
|
if (!res.ok) return
|
||||||
|
const payload = await res.json()
|
||||||
|
console.log('[Dashboard] GET /api/get-dashboard', { status: res.status, payload })
|
||||||
|
|
||||||
|
let tableData = payload?.data?.table_data ?? []
|
||||||
|
tableData = Array.isArray(tableData) ? tableData : []
|
||||||
|
|
||||||
|
if (objectTitle) {
|
||||||
|
tableData = tableData.filter((a: any) => a.object === objectTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSensorType && selectedSensorType !== '') {
|
||||||
|
tableData = tableData.filter((a: any) => {
|
||||||
|
return a.detector_type?.toLowerCase() === selectedSensorType.toLowerCase()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setDashboardAlerts(tableData as any[])
|
||||||
|
|
||||||
|
const cd = Array.isArray(payload?.data?.chart_data) ? payload.data.chart_data : []
|
||||||
|
setRawChartData(cd as any[])
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load dashboard:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadDashboard()
|
||||||
|
}, [objectTitle, selectedChartPeriod, selectedSensorType])
|
||||||
|
|
||||||
|
// Отдельный эффект для загрузки таблицы по выбранному периоду
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTableData = async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.append('time_period', selectedTablePeriod)
|
||||||
|
|
||||||
|
const res = await fetch(`/api/get-dashboard?${params.toString()}`, { cache: 'no-store' })
|
||||||
|
if (!res.ok) return
|
||||||
|
const payload = await res.json()
|
||||||
|
console.log('[Dashboard] GET /api/get-dashboard (table)', { status: res.status, payload })
|
||||||
|
|
||||||
|
let tableData = payload?.data?.table_data ?? []
|
||||||
|
tableData = Array.isArray(tableData) ? tableData : []
|
||||||
|
|
||||||
|
if (objectTitle) {
|
||||||
|
tableData = tableData.filter((a: any) => a.object === objectTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSensorType && selectedSensorType !== '') {
|
||||||
|
tableData = tableData.filter((a: any) => {
|
||||||
|
return a.detector_type?.toLowerCase() === selectedSensorType.toLowerCase()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setDashboardAlerts(tableData as any[])
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load table data:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadTableData()
|
||||||
|
}, [objectTitle, selectedTablePeriod, selectedSensorType])
|
||||||
|
|
||||||
|
const handleBackClick = () => {
|
||||||
|
router.push('/objects')
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredAlerts = dashboardAlerts.filter((alert: any) => {
|
||||||
|
if (selectedSensorType === '') return true
|
||||||
|
return alert.detector_type?.toLowerCase() === selectedSensorType.toLowerCase()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Статусы
|
||||||
|
const statusCounts = filteredAlerts.reduce((acc: { critical: number; warning: number; normal: number }, a: any) => {
|
||||||
|
if (a.severity === 'critical') acc.critical++
|
||||||
|
else if (a.severity === 'warning') acc.warning++
|
||||||
|
else acc.normal++
|
||||||
|
return acc
|
||||||
|
}, { critical: 0, warning: 0, normal: 0 })
|
||||||
|
|
||||||
|
const handleNavigationClick = () => {
|
||||||
|
closeMonitoring()
|
||||||
|
closeFloorNavigation()
|
||||||
|
closeNotifications()
|
||||||
|
setCurrentSubmenu(null)
|
||||||
|
router.push('/navigation')
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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 (
|
||||||
|
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-20">
|
||||||
|
<Sidebar
|
||||||
|
activeItem={1} // Dashboard
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex-1 flex flex-col">
|
||||||
|
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={handleBackClick}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
|
aria-label="Назад к объектам"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<nav className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-gray-400">Объекты</span>
|
||||||
|
<span className="text-gray-600">/</span>
|
||||||
|
<span className="text-white">{objectTitle || 'Объект'}</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 p-6 overflow-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 style={interSemiboldStyle} className="text-white text-2xl mb-6">{objectTitle || 'Объект'}</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={selectedSensorType}
|
||||||
|
onChange={(e) => handleSensorTypeChange(e.target.value)}
|
||||||
|
className="flex items-center gap-6 rounded-[10px] px-4 py-[18px] bg-[rgb(22,24,36)] text-white appearance-none pr-8"
|
||||||
|
>
|
||||||
|
{sensorTypes.map((type) => (
|
||||||
|
<option key={type.code} value={type.code}>
|
||||||
|
{type.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<svg className="w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 ml-auto">
|
||||||
|
<button
|
||||||
|
onClick={handleNavigationClick}
|
||||||
|
className="rounded-[10px] px-4 py-[18px] bg-gray-600 text-gray-300 hover:bg-[rgb(22,24,36)] hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">Навигация</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={selectedChartPeriod}
|
||||||
|
onChange={(e) => handleChartPeriodChange(e.target.value)}
|
||||||
|
className="flex items-center gap-2 bg-[rgb(22,24,36)] rounded-lg px-3 py-2 text-white appearance-none pr-8"
|
||||||
|
>
|
||||||
|
<option value="24">День</option>
|
||||||
|
<option value="72">3 дня</option>
|
||||||
|
<option value="168">Неделя</option>
|
||||||
|
<option value="720">Месяц</option>
|
||||||
|
</select>
|
||||||
|
<svg className="w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Карты-графики */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px]">
|
||||||
|
<ChartCard
|
||||||
|
title="Показатель"
|
||||||
|
>
|
||||||
|
<AreaChart data={chartData} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
title="Статистика"
|
||||||
|
>
|
||||||
|
<BarChart data={chartData?.map((d: any) => ({ value: d.value, label: d.label }))} />
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список детекторов */}
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 style={interSemiboldStyle} className="text-white text-2xl">Тренды</h2>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={selectedTablePeriod}
|
||||||
|
onChange={(e) => handleTablePeriodChange(e.target.value)}
|
||||||
|
className="bg-[#161824] rounded-lg px-3 py-2 flex items-center gap-2 text-white appearance-none pr-8"
|
||||||
|
>
|
||||||
|
<option value="24">День</option>
|
||||||
|
<option value="72">3 дня</option>
|
||||||
|
<option value="168">Неделя</option>
|
||||||
|
<option value="720">Месяц</option>
|
||||||
|
</select>
|
||||||
|
<svg className="w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Таблица */}
|
||||||
|
<div className="bg-[#161824] rounded-[20px] p-6">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-700">
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Серьезность</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Дата</th>
|
||||||
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Решен</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredAlerts.map((alert: any) => (
|
||||||
|
<tr key={alert.id} className="border-b border-gray-800">
|
||||||
|
<td style={interRegularStyle} className="py-3 text-white text-sm">{alert.name}</td>
|
||||||
|
<td style={interRegularStyle} className="py-3 text-gray-300 text-sm">{alert.message}</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<span style={interRegularStyle} className={`text-sm ${alert.severity === 'critical' ? 'text-red-500' : alert.severity === 'warning' ? 'text-orange-500' : 'text-green-500'}`}>
|
||||||
|
{alert.severity === 'critical' ? 'Критическое' : alert.severity === 'warning' ? 'Предупреждение' : 'Норма'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={interRegularStyle} className="py-3 text-gray-400 text-sm">{new Date(alert.created_at).toLocaleString()}</td>
|
||||||
|
<td className="py-3">
|
||||||
|
{alert.resolved ? (
|
||||||
|
<span style={interRegularStyle} className="text-sm text-green-500">Да</span>
|
||||||
|
) : (
|
||||||
|
<span style={interRegularStyle} className="text-sm text-gray-500">Нет</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Статистика */}
|
||||||
|
<div className="mt-6 grid grid-cols-4 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div style={interSemiboldStyle} className="text-2xl text-white">{filteredAlerts.length}</div>
|
||||||
|
<div style={interRegularStyle} className="text-sm text-gray-400">Всего</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div style={interSemiboldStyle} className="text-2xl text-green-500">{statusCounts.normal}</div>
|
||||||
|
<div style={interRegularStyle} className="text-sm text-gray-400">Норма</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div style={interSemiboldStyle} className="text-2xl text-orange-500">{statusCounts.warning}</div>
|
||||||
|
<div style={interRegularStyle} className="text-sm text-gray-400">Предупреждения</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div style={interSemiboldStyle} className="text-2xl text-red-500">{statusCounts.critical}</div>
|
||||||
|
<div style={interRegularStyle} className="text-sm text-gray-400">Критические</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Dashboard
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
PointerEventTypes,
|
PointerEventTypes,
|
||||||
PointerInfo,
|
PointerInfo,
|
||||||
Matrix,
|
Matrix,
|
||||||
|
Ray,
|
||||||
} from '@babylonjs/core'
|
} from '@babylonjs/core'
|
||||||
import '@babylonjs/loaders'
|
import '@babylonjs/loaders'
|
||||||
|
|
||||||
@@ -340,7 +341,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
|
|
||||||
let engine: Engine
|
let engine: Engine
|
||||||
try {
|
try {
|
||||||
engine = new Engine(canvas, true, { stencil: true })
|
// Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU
|
||||||
|
engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
const message = `WebGL недоступен: ${errorMessage}`
|
const message = `WebGL недоступен: ${errorMessage}`
|
||||||
@@ -362,6 +364,9 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
sceneRef.current = scene
|
sceneRef.current = scene
|
||||||
|
|
||||||
scene.clearColor = new Color4(0.1, 0.1, 0.15, 1)
|
scene.clearColor = new Color4(0.1, 0.1, 0.15, 1)
|
||||||
|
|
||||||
|
// Оптимизация: включаем FXAA (более легковесное сглаживание)
|
||||||
|
scene.imageProcessingConfiguration.fxaaEnabled = true
|
||||||
|
|
||||||
const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene)
|
const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene)
|
||||||
camera.attachControl(canvas, true)
|
camera.attachControl(canvas, true)
|
||||||
@@ -689,16 +694,65 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
const maxDimension = Math.max(size.x, size.y, size.z)
|
const maxDimension = Math.max(size.x, size.y, size.z)
|
||||||
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
|
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
|
||||||
|
|
||||||
|
// Простое позиционирование камеры - всегда поворачиваемся к датчику
|
||||||
|
console.log('[ModelViewer] Calculating camera direction to sensor')
|
||||||
|
|
||||||
|
// Вычисляем направление от текущей позиции камеры к датчику
|
||||||
|
const directionToSensor = center.subtract(camera.position).normalize()
|
||||||
|
|
||||||
|
// Преобразуем в сферические координаты
|
||||||
|
// alpha - горизонтальный угол (вокруг оси Y)
|
||||||
|
let targetAlpha = Math.atan2(directionToSensor.x, directionToSensor.z)
|
||||||
|
|
||||||
|
// beta - вертикальный угол (от вертикали)
|
||||||
|
// Используем оптимальный угол 60° для обзора
|
||||||
|
let targetBeta = Math.PI / 3 // 60°
|
||||||
|
|
||||||
|
console.log('[ModelViewer] Calculated camera direction:', {
|
||||||
|
alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°',
|
||||||
|
beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°',
|
||||||
|
sensorPosition: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) },
|
||||||
|
cameraPosition: { x: camera.position.x.toFixed(2), y: camera.position.y.toFixed(2), z: camera.position.z.toFixed(2) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Нормализуем alpha в диапазон [-PI, PI]
|
||||||
|
while (targetAlpha > Math.PI) targetAlpha -= 2 * Math.PI
|
||||||
|
while (targetAlpha < -Math.PI) targetAlpha += 2 * Math.PI
|
||||||
|
|
||||||
|
// Ограничиваем beta в разумных пределах
|
||||||
|
targetBeta = Math.max(0.1, Math.min(Math.PI - 0.1, targetBeta))
|
||||||
|
|
||||||
scene.stopAnimation(camera)
|
scene.stopAnimation(camera)
|
||||||
|
|
||||||
|
// Логирование перед анимацией
|
||||||
|
console.log('[ModelViewer] Starting camera animation:', {
|
||||||
|
sensorId,
|
||||||
|
from: {
|
||||||
|
target: { x: camera.target.x.toFixed(2), y: camera.target.y.toFixed(2), z: camera.target.z.toFixed(2) },
|
||||||
|
radius: camera.radius.toFixed(2),
|
||||||
|
alpha: (camera.alpha * 180 / Math.PI).toFixed(1) + '°',
|
||||||
|
beta: (camera.beta * 180 / Math.PI).toFixed(1) + '°'
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
target: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) },
|
||||||
|
radius: targetRadius.toFixed(2),
|
||||||
|
alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°',
|
||||||
|
beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°'
|
||||||
|
},
|
||||||
|
alphaChange: ((targetAlpha - camera.alpha) * 180 / Math.PI).toFixed(1) + '°',
|
||||||
|
betaChange: ((targetBeta - camera.beta) * 180 / Math.PI).toFixed(1) + '°'
|
||||||
|
})
|
||||||
|
|
||||||
const ease = new CubicEase()
|
const ease = new CubicEase()
|
||||||
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
||||||
const frameRate = 60
|
const frameRate = 60
|
||||||
const durationMs = 600
|
const durationMs = 800
|
||||||
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||||
|
|
||||||
Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||||
Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||||
|
Animation.CreateAndStartAnimation('camAlpha', camera, 'alpha', frameRate, totalFrames, camera.alpha, targetAlpha, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||||
|
Animation.CreateAndStartAnimation('camBeta', camera, 'beta', frameRate, totalFrames, camera.beta, targetBeta, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||||
|
|
||||||
applyHighlightToMeshes(
|
applyHighlightToMeshes(
|
||||||
highlightLayerRef.current,
|
highlightLayerRef.current,
|
||||||
|
|||||||
914
frontend/components/model/ModelViewer.tsx — копия 3
Normal file
914
frontend/components/model/ModelViewer.tsx — копия 3
Normal file
@@ -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<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||||
|
modelPath,
|
||||||
|
onSelectModel,
|
||||||
|
onModelLoaded,
|
||||||
|
onError,
|
||||||
|
focusSensorId,
|
||||||
|
renderOverlay,
|
||||||
|
isSensorSelectionEnabled,
|
||||||
|
onSensorPick,
|
||||||
|
highlightAllSensors,
|
||||||
|
sensorStatusMap,
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const engineRef = useRef<Nullable<Engine>>(null)
|
||||||
|
const sceneRef = useRef<Nullable<Scene>>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||||
|
const [showModel, setShowModel] = useState(false)
|
||||||
|
const isInitializedRef = useRef(false)
|
||||||
|
const isDisposedRef = useRef(false)
|
||||||
|
const importedMeshesRef = useRef<AbstractMesh[]>([])
|
||||||
|
const highlightLayerRef = useRef<HighlightLayer | null>(null)
|
||||||
|
const highlightedMeshesRef = useRef<AbstractMesh[]>([])
|
||||||
|
const chosenMeshRef = useRef<AbstractMesh | null>(null)
|
||||||
|
const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null)
|
||||||
|
const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null)
|
||||||
|
const [modelReady, setModelReady] = useState(false)
|
||||||
|
const [panActive, setPanActive] = useState(false);
|
||||||
|
const [webglError, setWebglError] = useState<string | null>(null)
|
||||||
|
const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState<
|
||||||
|
{ sensorId: string; left: number; top: number; colorHex: string }[]
|
||||||
|
>([])
|
||||||
|
// NEW: State for tracking hovered sensor in overlay circles
|
||||||
|
const [hoveredSensorId, setHoveredSensorId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handlePan = () => setPanActive(!panActive);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scene = sceneRef.current;
|
||||||
|
const camera = scene?.activeCamera as ArcRotateCamera;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
|
||||||
|
if (!scene || !camera || !canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let observer: any = null;
|
||||||
|
|
||||||
|
if (panActive) {
|
||||||
|
camera.detachControl();
|
||||||
|
|
||||||
|
observer = scene.onPointerObservable.add((pointerInfo: PointerInfo) => {
|
||||||
|
const evt = pointerInfo.event;
|
||||||
|
|
||||||
|
if (evt.buttons === 1) {
|
||||||
|
camera.inertialPanningX -= evt.movementX / camera.panningSensibility;
|
||||||
|
camera.inertialPanningY += evt.movementY / camera.panningSensibility;
|
||||||
|
}
|
||||||
|
else if (evt.buttons === 2) {
|
||||||
|
camera.inertialAlphaOffset -= evt.movementX / camera.angularSensibilityX;
|
||||||
|
camera.inertialBetaOffset -= evt.movementY / camera.angularSensibilityY;
|
||||||
|
}
|
||||||
|
}, PointerEventTypes.POINTERMOVE);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
camera.detachControl();
|
||||||
|
camera.attachControl(canvas, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (observer) {
|
||||||
|
scene.onPointerObservable.remove(observer);
|
||||||
|
}
|
||||||
|
if (!camera.isDisposed() && !camera.inputs.attachedToElement) {
|
||||||
|
camera.attachControl(canvas, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [panActive, sceneRef, canvasRef]);
|
||||||
|
|
||||||
|
const handleZoomIn = () => {
|
||||||
|
const camera = sceneRef.current?.activeCamera as ArcRotateCamera
|
||||||
|
if (camera) {
|
||||||
|
sceneRef.current?.stopAnimation(camera)
|
||||||
|
const ease = new CubicEase()
|
||||||
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT)
|
||||||
|
|
||||||
|
const frameRate = 60
|
||||||
|
const durationMs = 300
|
||||||
|
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||||
|
|
||||||
|
const currentRadius = camera.radius
|
||||||
|
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 0.1, currentRadius * 0.8)
|
||||||
|
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'zoomIn',
|
||||||
|
camera,
|
||||||
|
'radius',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
currentRadius,
|
||||||
|
targetRadius,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleZoomOut = () => {
|
||||||
|
const camera = sceneRef.current?.activeCamera as ArcRotateCamera
|
||||||
|
if (camera) {
|
||||||
|
sceneRef.current?.stopAnimation(camera)
|
||||||
|
const ease = new CubicEase()
|
||||||
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT)
|
||||||
|
|
||||||
|
const frameRate = 60
|
||||||
|
const durationMs = 300
|
||||||
|
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||||
|
|
||||||
|
const currentRadius = camera.radius
|
||||||
|
const targetRadius = Math.min(camera.upperRadiusLimit ?? Infinity, currentRadius * 1.2)
|
||||||
|
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'zoomOut',
|
||||||
|
camera,
|
||||||
|
'radius',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
currentRadius,
|
||||||
|
targetRadius,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleTopView = () => {
|
||||||
|
const camera = sceneRef.current?.activeCamera as ArcRotateCamera;
|
||||||
|
if (camera) {
|
||||||
|
sceneRef.current?.stopAnimation(camera);
|
||||||
|
const ease = new CubicEase();
|
||||||
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT);
|
||||||
|
|
||||||
|
const frameRate = 60;
|
||||||
|
const durationMs = 500;
|
||||||
|
const totalFrames = Math.round((durationMs / 1000) * frameRate);
|
||||||
|
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'topViewAlpha',
|
||||||
|
camera,
|
||||||
|
'alpha',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
camera.alpha,
|
||||||
|
Math.PI / 2,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
);
|
||||||
|
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'topViewBeta',
|
||||||
|
camera,
|
||||||
|
'beta',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
camera.beta,
|
||||||
|
0,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// NEW: Function to handle overlay circle click
|
||||||
|
const handleOverlayCircleClick = (sensorId: string) => {
|
||||||
|
console.log('[ModelViewer] Overlay circle clicked:', sensorId)
|
||||||
|
|
||||||
|
// Find the mesh for this sensor
|
||||||
|
const allMeshes = importedMeshesRef.current || []
|
||||||
|
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||||
|
const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
|
||||||
|
|
||||||
|
if (!targetMesh) {
|
||||||
|
console.warn(`[ModelViewer] Mesh not found for sensor: ${sensorId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scene = sceneRef.current
|
||||||
|
const camera = scene?.activeCamera as ArcRotateCamera
|
||||||
|
if (!scene || !camera) return
|
||||||
|
|
||||||
|
// Calculate bounding box of the sensor mesh
|
||||||
|
const bbox = (typeof targetMesh.getHierarchyBoundingVectors === 'function')
|
||||||
|
? targetMesh.getHierarchyBoundingVectors()
|
||||||
|
: {
|
||||||
|
min: targetMesh.getBoundingInfo().boundingBox.minimumWorld,
|
||||||
|
max: targetMesh.getBoundingInfo().boundingBox.maximumWorld
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = bbox.min.add(bbox.max).scale(0.5)
|
||||||
|
const size = bbox.max.subtract(bbox.min)
|
||||||
|
const maxDimension = Math.max(size.x, size.y, size.z)
|
||||||
|
|
||||||
|
// Calculate optimal camera distance
|
||||||
|
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
|
||||||
|
|
||||||
|
// Stop any current animations
|
||||||
|
scene.stopAnimation(camera)
|
||||||
|
|
||||||
|
// Setup easing
|
||||||
|
const ease = new CubicEase()
|
||||||
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
||||||
|
|
||||||
|
const frameRate = 60
|
||||||
|
const durationMs = 600 // 0.6 seconds for smooth animation
|
||||||
|
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||||
|
|
||||||
|
// Animate camera target position
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'camTarget',
|
||||||
|
camera,
|
||||||
|
'target',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
camera.target.clone(),
|
||||||
|
center.clone(),
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
)
|
||||||
|
|
||||||
|
// Animate camera radius (zoom)
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'camRadius',
|
||||||
|
camera,
|
||||||
|
'radius',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
camera.radius,
|
||||||
|
targetRadius,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call callback to display tooltip
|
||||||
|
onSensorPick?.(sensorId)
|
||||||
|
|
||||||
|
console.log('[ModelViewer] Camera animation started for sensor:', sensorId)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isDisposedRef.current = false
|
||||||
|
isInitializedRef.current = false
|
||||||
|
return () => {
|
||||||
|
isDisposedRef.current = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current || isInitializedRef.current) return
|
||||||
|
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
setWebglError(null)
|
||||||
|
|
||||||
|
let hasWebGL = false
|
||||||
|
try {
|
||||||
|
const testCanvas = document.createElement('canvas')
|
||||||
|
const gl =
|
||||||
|
testCanvas.getContext('webgl2') ||
|
||||||
|
testCanvas.getContext('webgl') ||
|
||||||
|
testCanvas.getContext('experimental-webgl')
|
||||||
|
hasWebGL = !!gl
|
||||||
|
} catch {
|
||||||
|
hasWebGL = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasWebGL) {
|
||||||
|
const message = 'WebGL не поддерживается в текущем окружении'
|
||||||
|
setWebglError(message)
|
||||||
|
onError?.(message)
|
||||||
|
setIsLoading(false)
|
||||||
|
setModelReady(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let engine: Engine
|
||||||
|
try {
|
||||||
|
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 (
|
||||||
|
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
||||||
|
{!modelPath ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
||||||
|
<div className="text-amber-400 text-lg font-semibold mb-2">
|
||||||
|
3D модель не выбрана
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-300 mb-4">
|
||||||
|
Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`w-full h-full outline-none block transition-opacity duration-500 ${
|
||||||
|
showModel && !webglError ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{webglError ? (
|
||||||
|
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
||||||
|
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
||||||
|
<div className="text-red-400 text-lg font-semibold mb-2">
|
||||||
|
3D просмотр недоступен
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-300 mb-4">
|
||||||
|
{webglError}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
||||||
|
<LoadingSpinner
|
||||||
|
progress={loadingProgress}
|
||||||
|
size={120}
|
||||||
|
strokeWidth={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : !modelReady ? (
|
||||||
|
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-40">
|
||||||
|
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
||||||
|
<div className="text-gray-400 text-lg font-semibold mb-4">
|
||||||
|
3D модель не загружена
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Модель не готова к отображению
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<SceneToolbar
|
||||||
|
onZoomIn={handleZoomIn}
|
||||||
|
onZoomOut={handleZoomOut}
|
||||||
|
onTopView={handleTopView}
|
||||||
|
onPan={handlePan}
|
||||||
|
onSelectModel={onSelectModel}
|
||||||
|
panActive={panActive}
|
||||||
|
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
|
||||||
|
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* UPDATED: Interactive overlay circles with hover effects */}
|
||||||
|
{allSensorsOverlayCircles.map(circle => {
|
||||||
|
const size = 36
|
||||||
|
const radius = size / 2
|
||||||
|
const fill = hexWithAlpha(circle.colorHex, 0.2)
|
||||||
|
const isHovered = hoveredSensorId === circle.sensorId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${circle.sensorId}-${Math.round(circle.left)}-${Math.round(circle.top)}`}
|
||||||
|
onClick={() => handleOverlayCircleClick(circle.sensorId)}
|
||||||
|
onMouseEnter={() => setHoveredSensorId(circle.sensorId)}
|
||||||
|
onMouseLeave={() => setHoveredSensorId(null)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: circle.left - radius,
|
||||||
|
top: circle.top - radius,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: '9999px',
|
||||||
|
border: `2px solid ${circle.colorHex}`,
|
||||||
|
backgroundColor: fill,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||||
|
transform: isHovered ? 'scale(1.4)' : 'scale(1)',
|
||||||
|
boxShadow: isHovered
|
||||||
|
? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}`
|
||||||
|
: `0 0 8px ${circle.colorHex}`,
|
||||||
|
zIndex: isHovered ? 50 : 10,
|
||||||
|
}}
|
||||||
|
title={`Датчик: ${circle.sensorId}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{renderOverlay && overlayPos && overlayData
|
||||||
|
? renderOverlay({ anchor: overlayPos, info: overlayData })
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModelViewer
|
||||||
940
frontend/components/model/ModelViewer.tsx — копия 4
Normal file
940
frontend/components/model/ModelViewer.tsx — копия 4
Normal file
@@ -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<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||||
|
modelPath,
|
||||||
|
onSelectModel,
|
||||||
|
onModelLoaded,
|
||||||
|
onError,
|
||||||
|
focusSensorId,
|
||||||
|
renderOverlay,
|
||||||
|
isSensorSelectionEnabled,
|
||||||
|
onSensorPick,
|
||||||
|
highlightAllSensors,
|
||||||
|
sensorStatusMap,
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const engineRef = useRef<Nullable<Engine>>(null)
|
||||||
|
const sceneRef = useRef<Nullable<Scene>>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||||
|
const [showModel, setShowModel] = useState(false)
|
||||||
|
const isInitializedRef = useRef(false)
|
||||||
|
const isDisposedRef = useRef(false)
|
||||||
|
const importedMeshesRef = useRef<AbstractMesh[]>([])
|
||||||
|
const highlightLayerRef = useRef<HighlightLayer | null>(null)
|
||||||
|
const highlightedMeshesRef = useRef<AbstractMesh[]>([])
|
||||||
|
const chosenMeshRef = useRef<AbstractMesh | null>(null)
|
||||||
|
const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null)
|
||||||
|
const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null)
|
||||||
|
const [modelReady, setModelReady] = useState(false)
|
||||||
|
const [panActive, setPanActive] = useState(false);
|
||||||
|
const [webglError, setWebglError] = useState<string | null>(null)
|
||||||
|
const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState<
|
||||||
|
{ sensorId: string; left: number; top: number; colorHex: string }[]
|
||||||
|
>([])
|
||||||
|
// NEW: State for tracking hovered sensor in overlay circles
|
||||||
|
const [hoveredSensorId, setHoveredSensorId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handlePan = () => setPanActive(!panActive);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scene = sceneRef.current;
|
||||||
|
const camera = scene?.activeCamera as ArcRotateCamera;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
|
||||||
|
if (!scene || !camera || !canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let observer: any = null;
|
||||||
|
|
||||||
|
if (panActive) {
|
||||||
|
camera.detachControl();
|
||||||
|
|
||||||
|
observer = scene.onPointerObservable.add((pointerInfo: PointerInfo) => {
|
||||||
|
const evt = pointerInfo.event;
|
||||||
|
|
||||||
|
if (evt.buttons === 1) {
|
||||||
|
camera.inertialPanningX -= evt.movementX / camera.panningSensibility;
|
||||||
|
camera.inertialPanningY += evt.movementY / camera.panningSensibility;
|
||||||
|
}
|
||||||
|
else if (evt.buttons === 2) {
|
||||||
|
camera.inertialAlphaOffset -= evt.movementX / camera.angularSensibilityX;
|
||||||
|
camera.inertialBetaOffset -= evt.movementY / camera.angularSensibilityY;
|
||||||
|
}
|
||||||
|
}, PointerEventTypes.POINTERMOVE);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
camera.detachControl();
|
||||||
|
camera.attachControl(canvas, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (observer) {
|
||||||
|
scene.onPointerObservable.remove(observer);
|
||||||
|
}
|
||||||
|
if (!camera.isDisposed() && !camera.inputs.attachedToElement) {
|
||||||
|
camera.attachControl(canvas, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [panActive, sceneRef, canvasRef]);
|
||||||
|
|
||||||
|
const handleZoomIn = () => {
|
||||||
|
const camera = sceneRef.current?.activeCamera as ArcRotateCamera
|
||||||
|
if (camera) {
|
||||||
|
sceneRef.current?.stopAnimation(camera)
|
||||||
|
const ease = new CubicEase()
|
||||||
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT)
|
||||||
|
|
||||||
|
const frameRate = 60
|
||||||
|
const durationMs = 300
|
||||||
|
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||||
|
|
||||||
|
const currentRadius = camera.radius
|
||||||
|
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 0.1, currentRadius * 0.8)
|
||||||
|
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'zoomIn',
|
||||||
|
camera,
|
||||||
|
'radius',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
currentRadius,
|
||||||
|
targetRadius,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleZoomOut = () => {
|
||||||
|
const camera = sceneRef.current?.activeCamera as ArcRotateCamera
|
||||||
|
if (camera) {
|
||||||
|
sceneRef.current?.stopAnimation(camera)
|
||||||
|
const ease = new CubicEase()
|
||||||
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT)
|
||||||
|
|
||||||
|
const frameRate = 60
|
||||||
|
const durationMs = 300
|
||||||
|
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||||
|
|
||||||
|
const currentRadius = camera.radius
|
||||||
|
const targetRadius = Math.min(camera.upperRadiusLimit ?? Infinity, currentRadius * 1.2)
|
||||||
|
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'zoomOut',
|
||||||
|
camera,
|
||||||
|
'radius',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
currentRadius,
|
||||||
|
targetRadius,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleTopView = () => {
|
||||||
|
const camera = sceneRef.current?.activeCamera as ArcRotateCamera;
|
||||||
|
if (camera) {
|
||||||
|
sceneRef.current?.stopAnimation(camera);
|
||||||
|
const ease = new CubicEase();
|
||||||
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT);
|
||||||
|
|
||||||
|
const frameRate = 60;
|
||||||
|
const durationMs = 500;
|
||||||
|
const totalFrames = Math.round((durationMs / 1000) * frameRate);
|
||||||
|
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'topViewAlpha',
|
||||||
|
camera,
|
||||||
|
'alpha',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
camera.alpha,
|
||||||
|
Math.PI / 2,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
);
|
||||||
|
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'topViewBeta',
|
||||||
|
camera,
|
||||||
|
'beta',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
camera.beta,
|
||||||
|
0,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// NEW: Function to handle overlay circle click
|
||||||
|
const handleOverlayCircleClick = (sensorId: string) => {
|
||||||
|
console.log('[ModelViewer] Overlay circle clicked:', sensorId)
|
||||||
|
|
||||||
|
// Find the mesh for this sensor
|
||||||
|
const allMeshes = importedMeshesRef.current || []
|
||||||
|
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||||
|
const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
|
||||||
|
|
||||||
|
if (!targetMesh) {
|
||||||
|
console.warn(`[ModelViewer] Mesh not found for sensor: ${sensorId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scene = sceneRef.current
|
||||||
|
const camera = scene?.activeCamera as ArcRotateCamera
|
||||||
|
if (!scene || !camera) return
|
||||||
|
|
||||||
|
// Calculate bounding box of the sensor mesh
|
||||||
|
const bbox = (typeof targetMesh.getHierarchyBoundingVectors === 'function')
|
||||||
|
? targetMesh.getHierarchyBoundingVectors()
|
||||||
|
: {
|
||||||
|
min: targetMesh.getBoundingInfo().boundingBox.minimumWorld,
|
||||||
|
max: targetMesh.getBoundingInfo().boundingBox.maximumWorld
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = bbox.min.add(bbox.max).scale(0.5)
|
||||||
|
const size = bbox.max.subtract(bbox.min)
|
||||||
|
const maxDimension = Math.max(size.x, size.y, size.z)
|
||||||
|
|
||||||
|
// Calculate optimal camera distance
|
||||||
|
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
|
||||||
|
|
||||||
|
// Stop any current animations
|
||||||
|
scene.stopAnimation(camera)
|
||||||
|
|
||||||
|
// Setup easing
|
||||||
|
const ease = new CubicEase()
|
||||||
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
||||||
|
|
||||||
|
const frameRate = 60
|
||||||
|
const durationMs = 600 // 0.6 seconds for smooth animation
|
||||||
|
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||||
|
|
||||||
|
// Animate camera target position
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'camTarget',
|
||||||
|
camera,
|
||||||
|
'target',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
camera.target.clone(),
|
||||||
|
center.clone(),
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
)
|
||||||
|
|
||||||
|
// Animate camera radius (zoom)
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'camRadius',
|
||||||
|
camera,
|
||||||
|
'radius',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
camera.radius,
|
||||||
|
targetRadius,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call callback to display tooltip
|
||||||
|
onSensorPick?.(sensorId)
|
||||||
|
|
||||||
|
console.log('[ModelViewer] Camera animation started for sensor:', sensorId)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isDisposedRef.current = false
|
||||||
|
isInitializedRef.current = false
|
||||||
|
return () => {
|
||||||
|
isDisposedRef.current = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current || isInitializedRef.current) return
|
||||||
|
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
setWebglError(null)
|
||||||
|
|
||||||
|
let hasWebGL = false
|
||||||
|
try {
|
||||||
|
const testCanvas = document.createElement('canvas')
|
||||||
|
const gl =
|
||||||
|
testCanvas.getContext('webgl2') ||
|
||||||
|
testCanvas.getContext('webgl') ||
|
||||||
|
testCanvas.getContext('experimental-webgl')
|
||||||
|
hasWebGL = !!gl
|
||||||
|
} catch {
|
||||||
|
hasWebGL = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasWebGL) {
|
||||||
|
const message = 'WebGL не поддерживается в текущем окружении'
|
||||||
|
setWebglError(message)
|
||||||
|
onError?.(message)
|
||||||
|
setIsLoading(false)
|
||||||
|
setModelReady(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let engine: Engine
|
||||||
|
try {
|
||||||
|
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 (
|
||||||
|
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
||||||
|
{!modelPath ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
||||||
|
<div className="text-amber-400 text-lg font-semibold mb-2">
|
||||||
|
3D модель не выбрана
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-300 mb-4">
|
||||||
|
Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`w-full h-full outline-none block transition-opacity duration-500 ${
|
||||||
|
showModel && !webglError ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{webglError ? (
|
||||||
|
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
||||||
|
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
||||||
|
<div className="text-red-400 text-lg font-semibold mb-2">
|
||||||
|
3D просмотр недоступен
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-300 mb-4">
|
||||||
|
{webglError}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
||||||
|
<LoadingSpinner
|
||||||
|
progress={loadingProgress}
|
||||||
|
size={120}
|
||||||
|
strokeWidth={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : !modelReady ? (
|
||||||
|
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-40">
|
||||||
|
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
||||||
|
<div className="text-gray-400 text-lg font-semibold mb-4">
|
||||||
|
3D модель не загружена
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Модель не готова к отображению
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<SceneToolbar
|
||||||
|
onZoomIn={handleZoomIn}
|
||||||
|
onZoomOut={handleZoomOut}
|
||||||
|
onTopView={handleTopView}
|
||||||
|
onPan={handlePan}
|
||||||
|
onSelectModel={onSelectModel}
|
||||||
|
panActive={panActive}
|
||||||
|
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
|
||||||
|
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* UPDATED: Interactive overlay circles with hover effects */}
|
||||||
|
{allSensorsOverlayCircles.map(circle => {
|
||||||
|
const size = 36
|
||||||
|
const radius = size / 2
|
||||||
|
const fill = hexWithAlpha(circle.colorHex, 0.2)
|
||||||
|
const isHovered = hoveredSensorId === circle.sensorId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${circle.sensorId}-${Math.round(circle.left)}-${Math.round(circle.top)}`}
|
||||||
|
onClick={() => handleOverlayCircleClick(circle.sensorId)}
|
||||||
|
onMouseEnter={() => setHoveredSensorId(circle.sensorId)}
|
||||||
|
onMouseLeave={() => setHoveredSensorId(null)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: circle.left - radius,
|
||||||
|
top: circle.top - radius,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: '9999px',
|
||||||
|
border: `2px solid ${circle.colorHex}`,
|
||||||
|
backgroundColor: fill,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||||
|
transform: isHovered ? 'scale(1.4)' : 'scale(1)',
|
||||||
|
boxShadow: isHovered
|
||||||
|
? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}`
|
||||||
|
: `0 0 8px ${circle.colorHex}`,
|
||||||
|
zIndex: isHovered ? 50 : 10,
|
||||||
|
}}
|
||||||
|
title={`Датчик: ${circle.sensorId}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{renderOverlay && overlayPos && overlayData
|
||||||
|
? renderOverlay({ anchor: overlayPos, info: overlayData })
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModelViewer
|
||||||
944
frontend/components/model/ModelViewer.tsx — копия 5
Normal file
944
frontend/components/model/ModelViewer.tsx — копия 5
Normal file
@@ -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<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||||
|
modelPath,
|
||||||
|
onSelectModel,
|
||||||
|
onModelLoaded,
|
||||||
|
onError,
|
||||||
|
focusSensorId,
|
||||||
|
renderOverlay,
|
||||||
|
isSensorSelectionEnabled,
|
||||||
|
onSensorPick,
|
||||||
|
highlightAllSensors,
|
||||||
|
sensorStatusMap,
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const engineRef = useRef<Nullable<Engine>>(null)
|
||||||
|
const sceneRef = useRef<Nullable<Scene>>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||||
|
const [showModel, setShowModel] = useState(false)
|
||||||
|
const isInitializedRef = useRef(false)
|
||||||
|
const isDisposedRef = useRef(false)
|
||||||
|
const importedMeshesRef = useRef<AbstractMesh[]>([])
|
||||||
|
const highlightLayerRef = useRef<HighlightLayer | null>(null)
|
||||||
|
const highlightedMeshesRef = useRef<AbstractMesh[]>([])
|
||||||
|
const chosenMeshRef = useRef<AbstractMesh | null>(null)
|
||||||
|
const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null)
|
||||||
|
const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null)
|
||||||
|
const [modelReady, setModelReady] = useState(false)
|
||||||
|
const [panActive, setPanActive] = useState(false);
|
||||||
|
const [webglError, setWebglError] = useState<string | null>(null)
|
||||||
|
const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState<
|
||||||
|
{ sensorId: string; left: number; top: number; colorHex: string }[]
|
||||||
|
>([])
|
||||||
|
// NEW: State for tracking hovered sensor in overlay circles
|
||||||
|
const [hoveredSensorId, setHoveredSensorId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handlePan = () => setPanActive(!panActive);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scene = sceneRef.current;
|
||||||
|
const camera = scene?.activeCamera as ArcRotateCamera;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
|
||||||
|
if (!scene || !camera || !canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let observer: any = null;
|
||||||
|
|
||||||
|
if (panActive) {
|
||||||
|
camera.detachControl();
|
||||||
|
|
||||||
|
observer = scene.onPointerObservable.add((pointerInfo: PointerInfo) => {
|
||||||
|
const evt = pointerInfo.event;
|
||||||
|
|
||||||
|
if (evt.buttons === 1) {
|
||||||
|
camera.inertialPanningX -= evt.movementX / camera.panningSensibility;
|
||||||
|
camera.inertialPanningY += evt.movementY / camera.panningSensibility;
|
||||||
|
}
|
||||||
|
else if (evt.buttons === 2) {
|
||||||
|
camera.inertialAlphaOffset -= evt.movementX / camera.angularSensibilityX;
|
||||||
|
camera.inertialBetaOffset -= evt.movementY / camera.angularSensibilityY;
|
||||||
|
}
|
||||||
|
}, PointerEventTypes.POINTERMOVE);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
camera.detachControl();
|
||||||
|
camera.attachControl(canvas, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (observer) {
|
||||||
|
scene.onPointerObservable.remove(observer);
|
||||||
|
}
|
||||||
|
if (!camera.isDisposed() && !camera.inputs.attachedToElement) {
|
||||||
|
camera.attachControl(canvas, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [panActive, sceneRef, canvasRef]);
|
||||||
|
|
||||||
|
const handleZoomIn = () => {
|
||||||
|
const camera = sceneRef.current?.activeCamera as ArcRotateCamera
|
||||||
|
if (camera) {
|
||||||
|
sceneRef.current?.stopAnimation(camera)
|
||||||
|
const ease = new CubicEase()
|
||||||
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT)
|
||||||
|
|
||||||
|
const frameRate = 60
|
||||||
|
const durationMs = 300
|
||||||
|
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||||
|
|
||||||
|
const currentRadius = camera.radius
|
||||||
|
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 0.1, currentRadius * 0.8)
|
||||||
|
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'zoomIn',
|
||||||
|
camera,
|
||||||
|
'radius',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
currentRadius,
|
||||||
|
targetRadius,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleZoomOut = () => {
|
||||||
|
const camera = sceneRef.current?.activeCamera as ArcRotateCamera
|
||||||
|
if (camera) {
|
||||||
|
sceneRef.current?.stopAnimation(camera)
|
||||||
|
const ease = new CubicEase()
|
||||||
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT)
|
||||||
|
|
||||||
|
const frameRate = 60
|
||||||
|
const durationMs = 300
|
||||||
|
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||||
|
|
||||||
|
const currentRadius = camera.radius
|
||||||
|
const targetRadius = Math.min(camera.upperRadiusLimit ?? Infinity, currentRadius * 1.2)
|
||||||
|
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'zoomOut',
|
||||||
|
camera,
|
||||||
|
'radius',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
currentRadius,
|
||||||
|
targetRadius,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleTopView = () => {
|
||||||
|
const camera = sceneRef.current?.activeCamera as ArcRotateCamera;
|
||||||
|
if (camera) {
|
||||||
|
sceneRef.current?.stopAnimation(camera);
|
||||||
|
const ease = new CubicEase();
|
||||||
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT);
|
||||||
|
|
||||||
|
const frameRate = 60;
|
||||||
|
const durationMs = 500;
|
||||||
|
const totalFrames = Math.round((durationMs / 1000) * frameRate);
|
||||||
|
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'topViewAlpha',
|
||||||
|
camera,
|
||||||
|
'alpha',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
camera.alpha,
|
||||||
|
Math.PI / 2,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
);
|
||||||
|
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'topViewBeta',
|
||||||
|
camera,
|
||||||
|
'beta',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
camera.beta,
|
||||||
|
0,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// NEW: Function to handle overlay circle click
|
||||||
|
const handleOverlayCircleClick = (sensorId: string) => {
|
||||||
|
console.log('[ModelViewer] Overlay circle clicked:', sensorId)
|
||||||
|
|
||||||
|
// Find the mesh for this sensor
|
||||||
|
const allMeshes = importedMeshesRef.current || []
|
||||||
|
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||||
|
const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
|
||||||
|
|
||||||
|
if (!targetMesh) {
|
||||||
|
console.warn(`[ModelViewer] Mesh not found for sensor: ${sensorId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scene = sceneRef.current
|
||||||
|
const camera = scene?.activeCamera as ArcRotateCamera
|
||||||
|
if (!scene || !camera) return
|
||||||
|
|
||||||
|
// Calculate bounding box of the sensor mesh
|
||||||
|
const bbox = (typeof targetMesh.getHierarchyBoundingVectors === 'function')
|
||||||
|
? targetMesh.getHierarchyBoundingVectors()
|
||||||
|
: {
|
||||||
|
min: targetMesh.getBoundingInfo().boundingBox.minimumWorld,
|
||||||
|
max: targetMesh.getBoundingInfo().boundingBox.maximumWorld
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = bbox.min.add(bbox.max).scale(0.5)
|
||||||
|
const size = bbox.max.subtract(bbox.min)
|
||||||
|
const maxDimension = Math.max(size.x, size.y, size.z)
|
||||||
|
|
||||||
|
// Calculate optimal camera distance
|
||||||
|
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
|
||||||
|
|
||||||
|
// Stop any current animations
|
||||||
|
scene.stopAnimation(camera)
|
||||||
|
|
||||||
|
// Setup easing
|
||||||
|
const ease = new CubicEase()
|
||||||
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
||||||
|
|
||||||
|
const frameRate = 60
|
||||||
|
const durationMs = 600 // 0.6 seconds for smooth animation
|
||||||
|
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||||
|
|
||||||
|
// Animate camera target position
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'camTarget',
|
||||||
|
camera,
|
||||||
|
'target',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
camera.target.clone(),
|
||||||
|
center.clone(),
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
)
|
||||||
|
|
||||||
|
// Animate camera radius (zoom)
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'camRadius',
|
||||||
|
camera,
|
||||||
|
'radius',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
camera.radius,
|
||||||
|
targetRadius,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call callback to display tooltip
|
||||||
|
onSensorPick?.(sensorId)
|
||||||
|
|
||||||
|
console.log('[ModelViewer] Camera animation started for sensor:', sensorId)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isDisposedRef.current = false
|
||||||
|
isInitializedRef.current = false
|
||||||
|
return () => {
|
||||||
|
isDisposedRef.current = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current || isInitializedRef.current) return
|
||||||
|
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
setWebglError(null)
|
||||||
|
|
||||||
|
let hasWebGL = false
|
||||||
|
try {
|
||||||
|
const testCanvas = document.createElement('canvas')
|
||||||
|
const gl =
|
||||||
|
testCanvas.getContext('webgl2') ||
|
||||||
|
testCanvas.getContext('webgl') ||
|
||||||
|
testCanvas.getContext('experimental-webgl')
|
||||||
|
hasWebGL = !!gl
|
||||||
|
} catch {
|
||||||
|
hasWebGL = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasWebGL) {
|
||||||
|
const message = 'WebGL не поддерживается в текущем окружении'
|
||||||
|
setWebglError(message)
|
||||||
|
onError?.(message)
|
||||||
|
setIsLoading(false)
|
||||||
|
setModelReady(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let engine: Engine
|
||||||
|
try {
|
||||||
|
// Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU
|
||||||
|
engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
const message = `WebGL недоступен: ${errorMessage}`
|
||||||
|
setWebglError(message)
|
||||||
|
onError?.(message)
|
||||||
|
setIsLoading(false)
|
||||||
|
setModelReady(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
engineRef.current = engine
|
||||||
|
|
||||||
|
engine.runRenderLoop(() => {
|
||||||
|
if (!isDisposedRef.current && sceneRef.current) {
|
||||||
|
sceneRef.current.render()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const scene = new Scene(engine)
|
||||||
|
sceneRef.current = scene
|
||||||
|
|
||||||
|
scene.clearColor = new Color4(0.1, 0.1, 0.15, 1)
|
||||||
|
|
||||||
|
// Оптимизация: включаем FXAA (более легковесное сглаживание)
|
||||||
|
scene.imageProcessingConfiguration.fxaaEnabled = true
|
||||||
|
|
||||||
|
const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene)
|
||||||
|
camera.attachControl(canvas, true)
|
||||||
|
camera.lowerRadiusLimit = 2
|
||||||
|
camera.upperRadiusLimit = 200
|
||||||
|
camera.wheelDeltaPercentage = 0.01
|
||||||
|
camera.panningSensibility = 50
|
||||||
|
camera.angularSensibilityX = 1000
|
||||||
|
camera.angularSensibilityY = 1000
|
||||||
|
|
||||||
|
const ambientLight = new HemisphericLight('ambientLight', new Vector3(0, 1, 0), scene)
|
||||||
|
ambientLight.intensity = 0.4
|
||||||
|
ambientLight.diffuse = new Color3(0.7, 0.7, 0.8)
|
||||||
|
ambientLight.specular = new Color3(0.2, 0.2, 0.3)
|
||||||
|
ambientLight.groundColor = new Color3(0.3, 0.3, 0.4)
|
||||||
|
|
||||||
|
const keyLight = new HemisphericLight('keyLight', new Vector3(1, 1, 0), scene)
|
||||||
|
keyLight.intensity = 0.6
|
||||||
|
keyLight.diffuse = new Color3(1, 1, 0.9)
|
||||||
|
keyLight.specular = new Color3(1, 1, 0.9)
|
||||||
|
|
||||||
|
const fillLight = new HemisphericLight('fillLight', new Vector3(-1, 0.5, -1), scene)
|
||||||
|
fillLight.intensity = 0.3
|
||||||
|
fillLight.diffuse = new Color3(0.8, 0.8, 1)
|
||||||
|
|
||||||
|
const hl = new HighlightLayer('highlight-layer', scene, {
|
||||||
|
mainTextureRatio: 1,
|
||||||
|
blurTextureSizeRatio: 1,
|
||||||
|
})
|
||||||
|
hl.innerGlow = false
|
||||||
|
hl.outerGlow = true
|
||||||
|
hl.blurHorizontalSize = 2
|
||||||
|
hl.blurVerticalSize = 2
|
||||||
|
highlightLayerRef.current = hl
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!isDisposedRef.current) {
|
||||||
|
engine.resize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
|
||||||
|
isInitializedRef.current = true
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isDisposedRef.current = true
|
||||||
|
isInitializedRef.current = false
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
|
||||||
|
highlightLayerRef.current?.dispose()
|
||||||
|
highlightLayerRef.current = null
|
||||||
|
if (engineRef.current) {
|
||||||
|
engineRef.current.dispose()
|
||||||
|
engineRef.current = null
|
||||||
|
}
|
||||||
|
sceneRef.current = null
|
||||||
|
}
|
||||||
|
}, [onError])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modelPath || !sceneRef.current || !engineRef.current) return
|
||||||
|
|
||||||
|
const scene = sceneRef.current
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setLoadingProgress(0)
|
||||||
|
setShowModel(false)
|
||||||
|
setModelReady(false)
|
||||||
|
|
||||||
|
const loadModel = async () => {
|
||||||
|
try {
|
||||||
|
console.log('[ModelViewer] Starting to load model:', modelPath)
|
||||||
|
|
||||||
|
// UI элемент загрузчика (есть эффект замедленности)
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
setLoadingProgress(prev => {
|
||||||
|
if (prev >= 90) {
|
||||||
|
clearInterval(progressInterval)
|
||||||
|
return 90
|
||||||
|
}
|
||||||
|
return prev + Math.random() * 15
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
// Use the correct ImportMeshAsync signature: (url, scene, onProgress)
|
||||||
|
const result = await ImportMeshAsync(modelPath, scene, (evt) => {
|
||||||
|
if (evt.lengthComputable) {
|
||||||
|
const progress = (evt.loaded / evt.total) * 100
|
||||||
|
setLoadingProgress(progress)
|
||||||
|
console.log('[ModelViewer] Loading progress:', progress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
clearInterval(progressInterval)
|
||||||
|
|
||||||
|
if (isDisposedRef.current) {
|
||||||
|
console.log('[ModelViewer] Component disposed during load')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[ModelViewer] Model loaded successfully:', {
|
||||||
|
meshesCount: result.meshes.length,
|
||||||
|
particleSystemsCount: result.particleSystems.length,
|
||||||
|
skeletonsCount: result.skeletons.length,
|
||||||
|
animationGroupsCount: result.animationGroups.length
|
||||||
|
})
|
||||||
|
|
||||||
|
importedMeshesRef.current = result.meshes
|
||||||
|
|
||||||
|
if (result.meshes.length > 0) {
|
||||||
|
const boundingBox = result.meshes[0].getHierarchyBoundingVectors()
|
||||||
|
|
||||||
|
onModelLoaded?.({
|
||||||
|
meshes: result.meshes,
|
||||||
|
boundingBox: {
|
||||||
|
min: { x: boundingBox.min.x, y: boundingBox.min.y, z: boundingBox.min.z },
|
||||||
|
max: { x: boundingBox.max.x, y: boundingBox.max.y, z: boundingBox.max.z },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Автоматическое кадрирование камеры для отображения всей модели
|
||||||
|
const camera = scene.activeCamera as ArcRotateCamera
|
||||||
|
if (camera) {
|
||||||
|
const center = boundingBox.min.add(boundingBox.max).scale(0.5)
|
||||||
|
const size = boundingBox.max.subtract(boundingBox.min)
|
||||||
|
const maxDimension = Math.max(size.x, size.y, size.z)
|
||||||
|
|
||||||
|
// Устанавливаем оптимальное расстояние камеры
|
||||||
|
const targetRadius = maxDimension * 2.5 // Множитель для комфортного отступа
|
||||||
|
|
||||||
|
// Плавная анимация камеры к центру модели
|
||||||
|
scene.stopAnimation(camera)
|
||||||
|
|
||||||
|
const ease = new CubicEase()
|
||||||
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
||||||
|
|
||||||
|
const frameRate = 60
|
||||||
|
const durationMs = 800 // 0.8 секунды
|
||||||
|
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||||
|
|
||||||
|
// Анимация позиции камеры
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'frameCameraTarget',
|
||||||
|
camera,
|
||||||
|
'target',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
camera.target.clone(),
|
||||||
|
center.clone(),
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
)
|
||||||
|
|
||||||
|
// Анимация зума
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'frameCameraRadius',
|
||||||
|
camera,
|
||||||
|
'radius',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
camera.radius,
|
||||||
|
targetRadius,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('[ModelViewer] Camera framed to model:', { center, targetRadius, maxDimension })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingProgress(100)
|
||||||
|
setShowModel(true)
|
||||||
|
setModelReady(true)
|
||||||
|
setIsLoading(false)
|
||||||
|
} catch (error) {
|
||||||
|
if (isDisposedRef.current) return
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка'
|
||||||
|
console.error('[ModelViewer] Error loading model:', errorMessage)
|
||||||
|
const message = `Ошибка при загрузке модели: ${errorMessage}`
|
||||||
|
onError?.(message)
|
||||||
|
setIsLoading(false)
|
||||||
|
setModelReady(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadModel()
|
||||||
|
}, [modelPath, onModelLoaded, onError])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightAllSensors || focusSensorId || !modelReady) {
|
||||||
|
setAllSensorsOverlayCircles([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scene = sceneRef.current
|
||||||
|
const engine = engineRef.current
|
||||||
|
if (!scene || !engine) {
|
||||||
|
setAllSensorsOverlayCircles([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const allMeshes = importedMeshesRef.current || []
|
||||||
|
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||||
|
if (sensorMeshes.length === 0) {
|
||||||
|
setAllSensorsOverlayCircles([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const engineTyped = engine as Engine
|
||||||
|
const updateCircles = () => {
|
||||||
|
const circles = computeSensorOverlayCircles({
|
||||||
|
scene,
|
||||||
|
engine: engineTyped,
|
||||||
|
meshes: sensorMeshes,
|
||||||
|
sensorStatusMap,
|
||||||
|
})
|
||||||
|
setAllSensorsOverlayCircles(circles)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCircles()
|
||||||
|
const observer = scene.onBeforeRenderObservable.add(updateCircles)
|
||||||
|
return () => {
|
||||||
|
scene.onBeforeRenderObservable.remove(observer)
|
||||||
|
setAllSensorsOverlayCircles([])
|
||||||
|
}
|
||||||
|
}, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightAllSensors || focusSensorId || !modelReady) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scene = sceneRef.current
|
||||||
|
if (!scene) return
|
||||||
|
|
||||||
|
const allMeshes = importedMeshesRef.current || []
|
||||||
|
if (allMeshes.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||||
|
|
||||||
|
console.log('[ModelViewer] Total meshes in model:', allMeshes.length)
|
||||||
|
console.log('[ModelViewer] Sensor meshes found:', sensorMeshes.length)
|
||||||
|
|
||||||
|
// Log first 5 sensor IDs found in meshes
|
||||||
|
const sensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean).slice(0, 5)
|
||||||
|
console.log('[ModelViewer] Sample sensor IDs from meshes:', sensorIds)
|
||||||
|
|
||||||
|
if (sensorMeshes.length === 0) {
|
||||||
|
console.warn('[ModelViewer] No sensor meshes found in 3D model!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applyHighlightToMeshes(
|
||||||
|
highlightLayerRef.current,
|
||||||
|
highlightedMeshesRef,
|
||||||
|
sensorMeshes,
|
||||||
|
mesh => {
|
||||||
|
const sid = getSensorIdFromMesh(mesh)
|
||||||
|
const status = sid ? sensorStatusMap?.[sid] : undefined
|
||||||
|
return statusToColor3(status ?? null)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusSensorId || !modelReady) {
|
||||||
|
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||||
|
highlightedMeshesRef.current = []
|
||||||
|
highlightLayerRef.current?.removeAllMeshes()
|
||||||
|
chosenMeshRef.current = null
|
||||||
|
setOverlayPos(null)
|
||||||
|
setOverlayData(null)
|
||||||
|
setAllSensorsOverlayCircles([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensorId = (focusSensorId ?? '').trim()
|
||||||
|
if (!sensorId) {
|
||||||
|
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||||
|
highlightedMeshesRef.current = []
|
||||||
|
highlightLayerRef.current?.removeAllMeshes()
|
||||||
|
chosenMeshRef.current = null
|
||||||
|
setOverlayPos(null)
|
||||||
|
setOverlayData(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const allMeshes = importedMeshesRef.current || []
|
||||||
|
|
||||||
|
if (allMeshes.length === 0) {
|
||||||
|
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||||
|
highlightedMeshesRef.current = []
|
||||||
|
highlightLayerRef.current?.removeAllMeshes()
|
||||||
|
chosenMeshRef.current = null
|
||||||
|
setOverlayPos(null)
|
||||||
|
setOverlayData(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||||
|
const allSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m))
|
||||||
|
const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
|
||||||
|
|
||||||
|
console.log('[ModelViewer] Sensor focus', {
|
||||||
|
requested: sensorId,
|
||||||
|
totalImportedMeshes: allMeshes.length,
|
||||||
|
totalSensorMeshes: sensorMeshes.length,
|
||||||
|
allSensorIds: allSensorIds,
|
||||||
|
chosen: chosen ? { id: chosen.id, name: chosen.name, uniqueId: chosen.uniqueId, parent: chosen.parent?.name } : null,
|
||||||
|
source: 'result.meshes',
|
||||||
|
})
|
||||||
|
|
||||||
|
const scene = sceneRef.current!
|
||||||
|
|
||||||
|
if (chosen) {
|
||||||
|
try {
|
||||||
|
const camera = scene.activeCamera as ArcRotateCamera
|
||||||
|
const bbox = (typeof chosen.getHierarchyBoundingVectors === 'function')
|
||||||
|
? chosen.getHierarchyBoundingVectors()
|
||||||
|
: { min: chosen.getBoundingInfo().boundingBox.minimumWorld, max: chosen.getBoundingInfo().boundingBox.maximumWorld }
|
||||||
|
const center = bbox.min.add(bbox.max).scale(0.5)
|
||||||
|
const size = bbox.max.subtract(bbox.min)
|
||||||
|
const maxDimension = Math.max(size.x, size.y, size.z)
|
||||||
|
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
|
||||||
|
|
||||||
|
// Вычисляем оптимальные углы камеры для видимости датчика
|
||||||
|
// Позиционируем камеру спереди датчика с небольшим наклоном сверху
|
||||||
|
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 (
|
||||||
|
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
||||||
|
{!modelPath ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
||||||
|
<div className="text-amber-400 text-lg font-semibold mb-2">
|
||||||
|
3D модель не выбрана
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-300 mb-4">
|
||||||
|
Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`w-full h-full outline-none block transition-opacity duration-500 ${
|
||||||
|
showModel && !webglError ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{webglError ? (
|
||||||
|
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
||||||
|
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
||||||
|
<div className="text-red-400 text-lg font-semibold mb-2">
|
||||||
|
3D просмотр недоступен
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-300 mb-4">
|
||||||
|
{webglError}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
||||||
|
<LoadingSpinner
|
||||||
|
progress={loadingProgress}
|
||||||
|
size={120}
|
||||||
|
strokeWidth={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : !modelReady ? (
|
||||||
|
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-40">
|
||||||
|
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
||||||
|
<div className="text-gray-400 text-lg font-semibold mb-4">
|
||||||
|
3D модель не загружена
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Модель не готова к отображению
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<SceneToolbar
|
||||||
|
onZoomIn={handleZoomIn}
|
||||||
|
onZoomOut={handleZoomOut}
|
||||||
|
onTopView={handleTopView}
|
||||||
|
onPan={handlePan}
|
||||||
|
onSelectModel={onSelectModel}
|
||||||
|
panActive={panActive}
|
||||||
|
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
|
||||||
|
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* UPDATED: Interactive overlay circles with hover effects */}
|
||||||
|
{allSensorsOverlayCircles.map(circle => {
|
||||||
|
const size = 36
|
||||||
|
const radius = size / 2
|
||||||
|
const fill = hexWithAlpha(circle.colorHex, 0.2)
|
||||||
|
const isHovered = hoveredSensorId === circle.sensorId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${circle.sensorId}-${Math.round(circle.left)}-${Math.round(circle.top)}`}
|
||||||
|
onClick={() => handleOverlayCircleClick(circle.sensorId)}
|
||||||
|
onMouseEnter={() => setHoveredSensorId(circle.sensorId)}
|
||||||
|
onMouseLeave={() => setHoveredSensorId(null)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: circle.left - radius,
|
||||||
|
top: circle.top - radius,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: '9999px',
|
||||||
|
border: `2px solid ${circle.colorHex}`,
|
||||||
|
backgroundColor: fill,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||||
|
transform: isHovered ? 'scale(1.4)' : 'scale(1)',
|
||||||
|
boxShadow: isHovered
|
||||||
|
? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}`
|
||||||
|
: `0 0 8px ${circle.colorHex}`,
|
||||||
|
zIndex: isHovered ? 50 : 10,
|
||||||
|
}}
|
||||||
|
title={`Датчик: ${circle.sensorId}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{renderOverlay && overlayPos && overlayData
|
||||||
|
? renderOverlay({ anchor: overlayPos, info: overlayData })
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModelViewer
|
||||||
@@ -47,50 +47,6 @@ const SceneToolbar: React.FC<SceneToolbarProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHomeClick = async () => {
|
|
||||||
if (!onSelectModel) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let zones: Zone[] = Array.isArray(currentZones) ? currentZones : [];
|
|
||||||
|
|
||||||
// Если зоны ещё не загружены, откройте Monitoring и загрузите зоны для текущего объекта
|
|
||||||
if ((!zones || zones.length === 0) && currentObject?.id) {
|
|
||||||
if (!showMonitoring) {
|
|
||||||
openMonitoring();
|
|
||||||
}
|
|
||||||
await loadZones(currentObject.id);
|
|
||||||
zones = useNavigationStore.getState().currentZones || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(zones) || zones.length === 0) {
|
|
||||||
console.warn('No zones available to select a model from.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = zones.slice().sort((a: Zone, b: Zone) => {
|
|
||||||
const oa = typeof a.order === 'number' ? a.order : 0;
|
|
||||||
const ob = typeof b.order === 'number' ? b.order : 0;
|
|
||||||
if (oa !== ob) return oa - ob;
|
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
|
||||||
});
|
|
||||||
|
|
||||||
const top = sorted[0];
|
|
||||||
let chosenPath: string | null = top?.model_path && String(top.model_path).trim() ? top.model_path! : null;
|
|
||||||
if (!chosenPath) {
|
|
||||||
const nextWithModel = sorted.find((z) => z.model_path && String(z.model_path).trim());
|
|
||||||
chosenPath = nextWithModel?.model_path ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chosenPath) {
|
|
||||||
onSelectModel(chosenPath);
|
|
||||||
} else {
|
|
||||||
console.warn('No zone has a valid model_path to open.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error selecting top zone model:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultButtons: ToolbarButton[] = [
|
const defaultButtons: ToolbarButton[] = [
|
||||||
{
|
{
|
||||||
icon: '/icons/Zoom.png',
|
icon: '/icons/Zoom.png',
|
||||||
@@ -127,11 +83,6 @@ const SceneToolbar: React.FC<SceneToolbarProps> = ({
|
|||||||
onClick: onToggleSensorHighlights || (() => console.log('Toggle Sensor Highlights')),
|
onClick: onToggleSensorHighlights || (() => console.log('Toggle Sensor Highlights')),
|
||||||
active: sensorHighlightsActive,
|
active: sensorHighlightsActive,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: '/icons/Warehouse.png',
|
|
||||||
label: 'Домой',
|
|
||||||
onClick: handleHomeClick,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: '/icons/Layers.png',
|
icon: '/icons/Layers.png',
|
||||||
label: 'Уровни',
|
label: 'Уровни',
|
||||||
|
|||||||
217
frontend/components/model/SceneToolbar.tsx — копия 2
Normal file
217
frontend/components/model/SceneToolbar.tsx — копия 2
Normal file
@@ -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<SceneToolbarProps> = ({
|
||||||
|
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 (
|
||||||
|
<div className="fixed right-5 top-1/2 transform -translate-y-1/2 z-50">
|
||||||
|
<div className="flex flex-col gap-0">
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center gap-2 py-4 bg-[#161824] rounded-[15px] border border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.3)]"
|
||||||
|
style={{ minHeight: '320px' }}
|
||||||
|
>
|
||||||
|
{defaultButtons.map((button, index) => (
|
||||||
|
<div key={index} className="flex flex-col items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={button.onClick}
|
||||||
|
className={`
|
||||||
|
relative group flex items-center justify-center w-16 h-12 rounded-lg transition-all duration-200
|
||||||
|
hover:bg-blue-600/20 hover:scale-110 hover:shadow-lg
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500/50
|
||||||
|
${button.active
|
||||||
|
? 'bg-blue-600/30 text-blue-400 shadow-md'
|
||||||
|
: 'bg-transparent text-gray-300 hover:text-blue-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
title={button.label}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={button.icon}
|
||||||
|
alt={button.label}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="w-5 h-5 transition-transform duration-200 group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-full mr-3 top-1/2 transform -translate-y-1/2
|
||||||
|
opacity-0 group-hover:opacity-100 transition-opacity duration-200
|
||||||
|
pointer-events-none z-60">
|
||||||
|
<div className="bg-gray-900 text-white text-xs px-2 py-1 rounded
|
||||||
|
whitespace-nowrap shadow-lg border border-gray-700">
|
||||||
|
{button.label}
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-full top-1/2 transform -translate-y-1/2
|
||||||
|
w-0 h-0 border-t-4 border-t-transparent
|
||||||
|
border-b-4 border-b-transparent
|
||||||
|
border-l-4 border-l-gray-900">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{button.active && button.children && (
|
||||||
|
<div className="flex flex-col gap-2 mt-2">
|
||||||
|
{button.children.map((childButton, childIndex) => (
|
||||||
|
<button
|
||||||
|
key={childIndex}
|
||||||
|
onClick={childButton.onClick}
|
||||||
|
onMouseDown={childButton.onMouseDown}
|
||||||
|
onMouseUp={childButton.onMouseUp}
|
||||||
|
className="relative group flex items-center justify-center w-12 h-10 bg-gray-800/50 rounded-md transition-all duration-200 hover:bg-blue-600/30"
|
||||||
|
title={childButton.label}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={childButton.icon}
|
||||||
|
alt={childButton.label}
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SceneToolbar;
|
||||||
@@ -35,13 +35,22 @@ interface MonitoringProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
||||||
const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore();
|
const { currentObject, currentZones, zonesLoading, zonesError, loadZones, currentModelPath } = useNavigationStore();
|
||||||
|
const [autoSelectedRef, setAutoSelectedRef] = React.useState(false);
|
||||||
|
|
||||||
const handleSelectModel = useCallback((modelPath: string) => {
|
const handleSelectModel = useCallback((modelPath: string) => {
|
||||||
console.log(`[Monitoring] Model selected: ${modelPath}`);
|
console.log(`[Monitoring] Model selected: ${modelPath}`);
|
||||||
console.log(`[Monitoring] onSelectModel callback:`, onSelectModel);
|
console.log(`[Monitoring] onSelectModel callback:`, onSelectModel);
|
||||||
onSelectModel?.(modelPath);
|
onSelectModel?.(modelPath);
|
||||||
}, [onSelectModel]);
|
|
||||||
|
// Автоматически закрываем панель после выбора модели
|
||||||
|
if (onClose) {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('[Monitoring] Auto-closing after model selection');
|
||||||
|
onClose();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [onSelectModel, onClose]);
|
||||||
|
|
||||||
// Загрузка зон при изменении объекта
|
// Загрузка зон при изменении объекта
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,7 +60,19 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
loadZones(objId);
|
loadZones(objId);
|
||||||
}, [currentObject?.id, loadZones]);
|
}, [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 sortedZones: Zone[] = React.useMemo(() => {
|
||||||
const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
||||||
|
|||||||
201
frontend/components/navigation/Monitoring.tsx — копия 4
Normal file
201
frontend/components/navigation/Monitoring.tsx — копия 4
Normal file
@@ -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<MonitoringProps> = ({ 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 (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-white text-2xl font-semibold">Зоны мониторинга</h2>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* UI зон */}
|
||||||
|
{zonesError && (
|
||||||
|
<div className="rounded-lg bg-red-600/20 border border-red-600/40 text-red-200 text-xs px-3 py-2">
|
||||||
|
Ошибка загрузки зон: {zonesError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{zonesLoading && (
|
||||||
|
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
|
||||||
|
Загрузка зон...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sortedZones.length > 0 && (
|
||||||
|
<>
|
||||||
|
{sortedZones[0] && (
|
||||||
|
<button
|
||||||
|
key={`zone-${sortedZones[0].id}-panorama`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => sortedZones[0].model_path ? handleSelectModel(sortedZones[0].model_path) : null}
|
||||||
|
className="group w-full bg-gradient-to-br from-cyan-600/20 via-blue-600/20 to-purple-600/20 rounded-xl h-[220px] flex items-center justify-center hover:from-cyan-500/30 hover:via-blue-500/30 hover:to-purple-500/30 transition-all duration-300 mb-4 border border-cyan-400/30 hover:border-cyan-400/60 shadow-lg shadow-cyan-500/10 hover:shadow-cyan-500/30 overflow-hidden relative backdrop-blur-sm"
|
||||||
|
title={sortedZones[0].model_path ? `Открыть 3D модель зоны: ${sortedZones[0].name}` : 'Модель зоны отсутствует'}
|
||||||
|
disabled={!sortedZones[0].model_path}
|
||||||
|
>
|
||||||
|
{/* Градиентный фон при наведении */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-cyan-400/10 via-blue-400/10 to-purple-400/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||||
|
{/* Анимированный градиент по краям */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-cyan-400/5 to-transparent animate-pulse"></div>
|
||||||
|
|
||||||
|
<div className="w-full h-full rounded-lg flex flex-col items-center justify-center relative">
|
||||||
|
<Image
|
||||||
|
src={resolveImageSrc(sortedZones[0].image_path)}
|
||||||
|
alt={sortedZones[0].name || 'Зона'}
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
className="max-w-full max-h-full object-contain opacity-60 group-hover:opacity-80 transition-opacity duration-300"
|
||||||
|
style={{ height: 'auto' }}
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.src = '/images/test_image.png';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-3 left-3 right-3 text-sm font-medium text-white bg-gradient-to-r from-cyan-600/80 via-blue-600/80 to-purple-600/80 backdrop-blur-md rounded-lg px-4 py-2 truncate border border-cyan-400/40 shadow-lg shadow-cyan-500/20">
|
||||||
|
{sortedZones[0].name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{sortedZones.length > 1 && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{sortedZones.slice(1).map((zone: Zone, idx: number) => (
|
||||||
|
<button
|
||||||
|
key={`zone-${zone.id}-${idx}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => zone.model_path ? handleSelectModel(zone.model_path) : null}
|
||||||
|
className="group relative flex-1 bg-gradient-to-br from-emerald-600/20 via-teal-600/20 to-cyan-600/20 rounded-xl h-[140px] flex items-center justify-center hover:from-emerald-500/30 hover:via-teal-500/30 hover:to-cyan-500/30 transition-all duration-300 border border-emerald-400/30 hover:border-emerald-400/60 shadow-lg shadow-emerald-500/10 hover:shadow-emerald-500/30 overflow-hidden backdrop-blur-sm"
|
||||||
|
title={zone.model_path ? `Открыть 3D модель зоны: ${zone.name}` : 'Модель зоны отсутствует'}
|
||||||
|
disabled={!zone.model_path}
|
||||||
|
>
|
||||||
|
{/* Градиентный фон при наведении */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-emerald-400/10 via-teal-400/10 to-cyan-400/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||||
|
{/* Анимированный градиент по краям */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-emerald-400/5 to-transparent animate-pulse"></div>
|
||||||
|
|
||||||
|
<div className="w-full h-full rounded-lg flex flex-col items-center justify-center relative">
|
||||||
|
<Image
|
||||||
|
src={resolveImageSrc(zone.image_path)}
|
||||||
|
alt={zone.name || 'Зона'}
|
||||||
|
width={120}
|
||||||
|
height={120}
|
||||||
|
className="max-w-full max-h-full object-contain opacity-60 group-hover:opacity-80 transition-opacity duration-300"
|
||||||
|
style={{ height: 'auto' }}
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.src = '/images/test_image.png';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-2 left-2 right-2 text-[11px] font-medium text-white bg-gradient-to-r from-emerald-600/80 via-teal-600/80 to-cyan-600/80 backdrop-blur-md rounded-md px-2 py-1 truncate border border-emerald-400/40 shadow-md shadow-emerald-500/20">
|
||||||
|
{zone.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{sortedZones.length === 0 && !zonesError && !zonesLoading && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
|
||||||
|
Зоны не найдены для выбранного объекта. Проверьте параметр objectId в API /api/get-zones.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Monitoring;
|
||||||
BIN
frontend/public/icons/Building3D.png
Normal file
BIN
frontend/public/icons/Building3D.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 MiB |
BIN
frontend/public/icons/Floor3D.png
Normal file
BIN
frontend/public/icons/Floor3D.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
Reference in New Issue
Block a user