обновление бизнес логики
This commit is contained in:
@@ -215,7 +215,41 @@ const NavigationPage: React.FC = () => {
|
||||
const loadDetectors = async () => {
|
||||
try {
|
||||
setDetectorsError(null)
|
||||
const res = await fetch('/api/get-detectors', { cache: 'no-store' })
|
||||
|
||||
// Если есть modelPath и objectId - фильтруем по зоне
|
||||
let zoneId: string | null = null
|
||||
if (selectedModelPath && objectId) {
|
||||
try {
|
||||
// Получаем зоны для объекта
|
||||
const zonesRes = await fetch(`/api/get-zones?objectId=${objectId}`, { cache: 'no-store' })
|
||||
if (zonesRes.ok) {
|
||||
const zonesResponse = await zonesRes.json()
|
||||
// API возвращает { success: true, data: [...] }
|
||||
const zonesData = zonesResponse?.data || zonesResponse
|
||||
console.log('[NavigationPage] Loaded zones:', { count: Array.isArray(zonesData) ? zonesData.length : 0, zonesData })
|
||||
// Ищем зону по model_path
|
||||
if (Array.isArray(zonesData)) {
|
||||
const zone = zonesData.find((z: any) => z.model_path === selectedModelPath)
|
||||
if (zone) {
|
||||
zoneId = zone.id
|
||||
console.log('[NavigationPage] Found zone for model_path:', { modelPath: selectedModelPath, zoneId })
|
||||
} else {
|
||||
console.log('[NavigationPage] No zone found for model_path:', selectedModelPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[NavigationPage] Failed to load zones for filtering:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем датчики (с фильтром по зоне если найдена)
|
||||
const detectorsUrl = zoneId
|
||||
? `/api/get-detectors?zone_id=${zoneId}`
|
||||
: '/api/get-detectors'
|
||||
console.log('[NavigationPage] Loading detectors from:', detectorsUrl)
|
||||
|
||||
const res = await fetch(detectorsUrl, { cache: 'no-store' })
|
||||
const text = await res.text()
|
||||
let payload: any
|
||||
try { payload = JSON.parse(text) } catch { payload = text }
|
||||
@@ -232,7 +266,7 @@ const NavigationPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
loadDetectors()
|
||||
}, [])
|
||||
}, [selectedModelPath, objectId])
|
||||
|
||||
const handleBackClick = () => {
|
||||
router.push('/dashboard')
|
||||
|
||||
@@ -1,613 +0,0 @@
|
||||
'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
|
||||
} = 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={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
|
||||
@@ -1,615 +0,0 @@
|
||||
'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
|
||||
@@ -1,618 +0,0 @@
|
||||
'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 urlFocusSensorId = searchParams.get('focusSensorId')
|
||||
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) {
|
||||
// Всегда показываем меню детектора для всех датчиков
|
||||
handleDetectorMenuClick(detector);
|
||||
} else {
|
||||
setFocusedSensorId(null);
|
||||
closeDetectorMenu();
|
||||
closeAlertMenu();
|
||||
// If we're in Sensors menu and no valid detector found, highlight all sensors
|
||||
if (showSensors) {
|
||||
setHighlightAllSensors(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Обработка focusSensorId из URL (при переходе из таблиц событий)
|
||||
useEffect(() => {
|
||||
if (urlFocusSensorId && isModelReady && detectorsData) {
|
||||
console.log('[NavigationPage] Setting focusSensorId from URL:', urlFocusSensorId)
|
||||
setFocusedSensorId(urlFocusSensorId)
|
||||
setHighlightAllSensors(false)
|
||||
|
||||
// Автоматически открываем тултип датчика
|
||||
setTimeout(() => {
|
||||
handleSensorSelection(urlFocusSensorId)
|
||||
}, 500) // Задержка для полной инициализации
|
||||
|
||||
// Очищаем URL от параметра после применения
|
||||
const newUrl = new URL(window.location.href)
|
||||
newUrl.searchParams.delete('focusSensorId')
|
||||
window.history.replaceState({}, '', newUrl.toString())
|
||||
}
|
||||
}, [urlFocusSensorId, isModelReady, detectorsData])
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const s = (status || '').toLowerCase()
|
||||
switch (s) {
|
||||
case statusColors.STATUS_COLOR_CRITICAL:
|
||||
case 'critical':
|
||||
return 'Критический'
|
||||
case statusColors.STATUS_COLOR_WARNING:
|
||||
case 'warning':
|
||||
return 'Предупреждение'
|
||||
case statusColors.STATUS_COLOR_NORMAL:
|
||||
case 'normal':
|
||||
return 'Норма'
|
||||
default:
|
||||
return 'Неизвестно'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
||||
<AnimatedBackground />
|
||||
<div className="relative z-20">
|
||||
<Sidebar
|
||||
activeItem={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex-1 flex flex-col">
|
||||
|
||||
{showMonitoring && (
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<Monitoring
|
||||
onClose={closeMonitoring}
|
||||
onSelectModel={(path) => {
|
||||
console.log('[NavigationPage] Model selected:', path);
|
||||
setSelectedModelPath(path)
|
||||
setModelError(null)
|
||||
setIsModelReady(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFloorNavigation && (
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<FloorNavigation
|
||||
objectId={objectId || undefined}
|
||||
detectorsData={detectorsData}
|
||||
onDetectorMenuClick={handleDetectorMenuClick}
|
||||
onClose={closeFloorNavigation}
|
||||
is3DReady={isModelReady && !modelError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNotifications && (
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<Notifications
|
||||
objectId={objectId || undefined}
|
||||
detectorsData={detectorsData}
|
||||
onNotificationClick={handleNotificationClick}
|
||||
onClose={closeNotifications}
|
||||
/>
|
||||
{detectorsError && (
|
||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showListOfDetectors && (
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<ListOfDetectors
|
||||
objectId={objectId || undefined}
|
||||
detectorsData={detectorsData}
|
||||
onDetectorMenuClick={handleDetectorMenuClick}
|
||||
onClose={closeListOfDetectors}
|
||||
is3DReady={selectedModelPath ? !modelError : false}
|
||||
/>
|
||||
{detectorsError && (
|
||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSensors && (
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<Sensors
|
||||
objectId={objectId || undefined}
|
||||
detectorsData={detectorsData}
|
||||
onAlertClick={handleAlertClick}
|
||||
onClose={closeSensors}
|
||||
is3DReady={selectedModelPath ? !modelError : false}
|
||||
/>
|
||||
{detectorsError && (
|
||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNotifications && showNotificationDetectorInfo && selectedNotification && (() => {
|
||||
const detectorData = Object.values(detectorsData.detectors).find(
|
||||
detector => detector.detector_id === selectedNotification.detector_id
|
||||
);
|
||||
return detectorData ? (
|
||||
<div className="absolute left-[500px] top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<NotificationDetectorInfo
|
||||
detectorData={detectorData}
|
||||
onClose={closeNotificationDetectorInfo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{showFloorNavigation && showDetectorMenu && selectedDetector && (
|
||||
null
|
||||
)}
|
||||
|
||||
<header className="bg-[#161824] border-b border-gray-700 px-6 h-[73px] flex items-center">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleBackClick}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Назад к дашборду"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">Дашборд</span>
|
||||
<span className="text-gray-600">{'/'}</span>
|
||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
||||
<span className="text-gray-600">{'/'}</span>
|
||||
<span className="text-white">Навигация</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="h-full">
|
||||
{modelError ? (
|
||||
<>
|
||||
{console.log('[NavigationPage] Rendering error message, modelError:', modelError)}
|
||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
||||
<div className="text-red-400 text-lg font-semibold mb-4">
|
||||
Ошибка загрузки 3D модели
|
||||
</div>
|
||||
<div className="text-gray-300 mb-4">
|
||||
{modelError}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Используйте навигацию по этажам для просмотра детекторов
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : !selectedModelPath ? (
|
||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
||||
<div className="text-center p-8 flex flex-col items-center">
|
||||
<Image
|
||||
src="/icons/logo.png"
|
||||
alt="AerBIM HT Monitor"
|
||||
width={300}
|
||||
height={41}
|
||||
className="mb-6"
|
||||
/>
|
||||
<div className="text-gray-300 text-lg">
|
||||
Выберите модель для отображения
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ModelViewer
|
||||
key={selectedModelPath || 'no-model'}
|
||||
modelPath={selectedModelPath}
|
||||
onSelectModel={(path) => {
|
||||
console.log('[NavigationPage] Model selected:', path);
|
||||
setSelectedModelPath(path)
|
||||
setModelError(null)
|
||||
setIsModelReady(false)
|
||||
}}
|
||||
onModelLoaded={handleModelLoaded}
|
||||
onError={handleModelError}
|
||||
activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null}
|
||||
focusSensorId={focusedSensorId}
|
||||
highlightAllSensors={showSensorHighlights && highlightAllSensors}
|
||||
sensorStatusMap={sensorStatusMap}
|
||||
isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors}
|
||||
onSensorPick={handleSensorSelection}
|
||||
renderOverlay={({ anchor }) => (
|
||||
<>
|
||||
{selectedAlert && showAlertMenu && anchor ? (
|
||||
<AlertMenu
|
||||
alert={selectedAlert}
|
||||
isOpen={true}
|
||||
onClose={closeAlertMenu}
|
||||
getStatusText={getStatusText}
|
||||
compact={true}
|
||||
anchor={anchor}
|
||||
/>
|
||||
) : selectedDetector && showDetectorMenu && anchor ? (
|
||||
<DetectorMenu
|
||||
detector={selectedDetector}
|
||||
isOpen={true}
|
||||
onClose={closeDetectorMenu}
|
||||
getStatusText={getStatusText}
|
||||
compact={true}
|
||||
anchor={anchor}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NavigationPage
|
||||
@@ -1,620 +0,0 @@
|
||||
'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 [showStats, setShowStats] = 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 urlFocusSensorId = searchParams.get('focusSensorId')
|
||||
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) {
|
||||
// Всегда показываем меню детектора для всех датчиков
|
||||
handleDetectorMenuClick(detector);
|
||||
} else {
|
||||
setFocusedSensorId(null);
|
||||
closeDetectorMenu();
|
||||
closeAlertMenu();
|
||||
// If we're in Sensors menu and no valid detector found, highlight all sensors
|
||||
if (showSensors) {
|
||||
setHighlightAllSensors(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Обработка focusSensorId из URL (при переходе из таблиц событий)
|
||||
useEffect(() => {
|
||||
if (urlFocusSensorId && isModelReady && detectorsData) {
|
||||
console.log('[NavigationPage] Setting focusSensorId from URL:', urlFocusSensorId)
|
||||
setFocusedSensorId(urlFocusSensorId)
|
||||
setHighlightAllSensors(false)
|
||||
|
||||
// Автоматически открываем тултип датчика
|
||||
setTimeout(() => {
|
||||
handleSensorSelection(urlFocusSensorId)
|
||||
}, 500) // Задержка для полной инициализации
|
||||
|
||||
// Очищаем URL от параметра после применения
|
||||
const newUrl = new URL(window.location.href)
|
||||
newUrl.searchParams.delete('focusSensorId')
|
||||
window.history.replaceState({}, '', newUrl.toString())
|
||||
}
|
||||
}, [urlFocusSensorId, isModelReady, detectorsData])
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const s = (status || '').toLowerCase()
|
||||
switch (s) {
|
||||
case statusColors.STATUS_COLOR_CRITICAL:
|
||||
case 'critical':
|
||||
return 'Критический'
|
||||
case statusColors.STATUS_COLOR_WARNING:
|
||||
case 'warning':
|
||||
return 'Предупреждение'
|
||||
case statusColors.STATUS_COLOR_NORMAL:
|
||||
case 'normal':
|
||||
return 'Норма'
|
||||
default:
|
||||
return 'Неизвестно'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
||||
<AnimatedBackground />
|
||||
<div className="relative z-20">
|
||||
<Sidebar
|
||||
activeItem={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex-1 flex flex-col">
|
||||
|
||||
{showMonitoring && (
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<Monitoring
|
||||
onClose={closeMonitoring}
|
||||
onSelectModel={(path) => {
|
||||
console.log('[NavigationPage] Model selected:', path);
|
||||
setSelectedModelPath(path)
|
||||
setModelError(null)
|
||||
setIsModelReady(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFloorNavigation && (
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<FloorNavigation
|
||||
objectId={objectId || undefined}
|
||||
detectorsData={detectorsData}
|
||||
onDetectorMenuClick={handleDetectorMenuClick}
|
||||
onClose={closeFloorNavigation}
|
||||
is3DReady={isModelReady && !modelError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNotifications && (
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<Notifications
|
||||
objectId={objectId || undefined}
|
||||
detectorsData={detectorsData}
|
||||
onNotificationClick={handleNotificationClick}
|
||||
onClose={closeNotifications}
|
||||
/>
|
||||
{detectorsError && (
|
||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showListOfDetectors && (
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<ListOfDetectors
|
||||
objectId={objectId || undefined}
|
||||
detectorsData={detectorsData}
|
||||
onDetectorMenuClick={handleDetectorMenuClick}
|
||||
onClose={closeListOfDetectors}
|
||||
is3DReady={selectedModelPath ? !modelError : false}
|
||||
/>
|
||||
{detectorsError && (
|
||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSensors && (
|
||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<Sensors
|
||||
objectId={objectId || undefined}
|
||||
detectorsData={detectorsData}
|
||||
onAlertClick={handleAlertClick}
|
||||
onClose={closeSensors}
|
||||
is3DReady={selectedModelPath ? !modelError : false}
|
||||
/>
|
||||
{detectorsError && (
|
||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNotifications && showNotificationDetectorInfo && selectedNotification && (() => {
|
||||
const detectorData = Object.values(detectorsData.detectors).find(
|
||||
detector => detector.detector_id === selectedNotification.detector_id
|
||||
);
|
||||
return detectorData ? (
|
||||
<div className="absolute left-[500px] top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]">
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<NotificationDetectorInfo
|
||||
detectorData={detectorData}
|
||||
onClose={closeNotificationDetectorInfo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{showFloorNavigation && showDetectorMenu && selectedDetector && (
|
||||
null
|
||||
)}
|
||||
|
||||
<header className="bg-[#161824] border-b border-gray-700 px-6 h-[73px] flex items-center">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleBackClick}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Назад к дашборду"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">Дашборд</span>
|
||||
<span className="text-gray-600">{'/'}</span>
|
||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
||||
<span className="text-gray-600">{'/'}</span>
|
||||
<span className="text-white">Навигация</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="h-full">
|
||||
{modelError ? (
|
||||
<>
|
||||
{console.log('[NavigationPage] Rendering error message, modelError:', modelError)}
|
||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
||||
<div className="text-red-400 text-lg font-semibold mb-4">
|
||||
Ошибка загрузки 3D модели
|
||||
</div>
|
||||
<div className="text-gray-300 mb-4">
|
||||
{modelError}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Используйте навигацию по этажам для просмотра детекторов
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : !selectedModelPath ? (
|
||||
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
||||
<div className="text-center p-8 flex flex-col items-center">
|
||||
<Image
|
||||
src="/icons/logo.png"
|
||||
alt="AerBIM HT Monitor"
|
||||
width={300}
|
||||
height={41}
|
||||
className="mb-6"
|
||||
/>
|
||||
<div className="text-gray-300 text-lg">
|
||||
Выберите модель для отображения
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ModelViewer
|
||||
key={selectedModelPath || 'no-model'}
|
||||
modelPath={selectedModelPath}
|
||||
onSelectModel={(path) => {
|
||||
console.log('[NavigationPage] Model selected:', path);
|
||||
setSelectedModelPath(path)
|
||||
setModelError(null)
|
||||
setIsModelReady(false)
|
||||
}}
|
||||
onModelLoaded={handleModelLoaded}
|
||||
onError={handleModelError}
|
||||
activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null}
|
||||
focusSensorId={focusedSensorId}
|
||||
highlightAllSensors={showSensorHighlights && highlightAllSensors}
|
||||
sensorStatusMap={sensorStatusMap}
|
||||
isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors}
|
||||
onSensorPick={handleSensorSelection}
|
||||
showStats={showStats}
|
||||
onToggleStats={() => setShowStats(!showStats)}
|
||||
renderOverlay={({ anchor }) => (
|
||||
<>
|
||||
{selectedAlert && showAlertMenu && anchor ? (
|
||||
<AlertMenu
|
||||
alert={selectedAlert}
|
||||
isOpen={true}
|
||||
onClose={closeAlertMenu}
|
||||
getStatusText={getStatusText}
|
||||
compact={true}
|
||||
anchor={anchor}
|
||||
/>
|
||||
) : selectedDetector && showDetectorMenu && anchor ? (
|
||||
<DetectorMenu
|
||||
detector={selectedDetector}
|
||||
isOpen={true}
|
||||
onClose={closeDetectorMenu}
|
||||
getStatusText={getStatusText}
|
||||
compact={true}
|
||||
anchor={anchor}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NavigationPage
|
||||
@@ -3,7 +3,7 @@ import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import * as statusColors from '@/lib/statusColors'
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.accessToken) {
|
||||
@@ -11,9 +11,20 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const backendUrl = process.env.BACKEND_URL
|
||||
|
||||
// Получаем zone_id из query параметров
|
||||
const { searchParams } = new URL(request.url)
|
||||
const zoneId = searchParams.get('zone_id')
|
||||
|
||||
// Формируем URL для бэкенда с zone_id если он есть
|
||||
const detectorsUrl = zoneId
|
||||
? `${backendUrl}/account/get-detectors/?zone_id=${zoneId}`
|
||||
: `${backendUrl}/account/get-detectors/`
|
||||
|
||||
console.log('[get-detectors] Fetching from backend:', detectorsUrl)
|
||||
|
||||
const [detectorsRes, objectsRes] = await Promise.all([
|
||||
fetch(`${backendUrl}/account/get-detectors/`, {
|
||||
fetch(detectorsUrl, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
|
||||
117
frontend/app/api/get-detectors/route — копия 2.ts
Normal file
117
frontend/app/api/get-detectors/route — копия 2.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import * as statusColors from '@/lib/statusColors'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.accessToken) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const backendUrl = process.env.BACKEND_URL
|
||||
|
||||
const [detectorsRes, objectsRes] = await Promise.all([
|
||||
fetch(`${backendUrl}/account/get-detectors/`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
}),
|
||||
fetch(`${backendUrl}/account/get-objects/`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
}),
|
||||
])
|
||||
|
||||
if (!detectorsRes.ok) {
|
||||
const err = await detectorsRes.text()
|
||||
return NextResponse.json({ success: false, error: `Backend detectors error: ${err}` }, { status: detectorsRes.status })
|
||||
}
|
||||
if (!objectsRes.ok) {
|
||||
const err = await objectsRes.text()
|
||||
return NextResponse.json({ success: false, error: `Backend objects error: ${err}` }, { status: objectsRes.status })
|
||||
}
|
||||
|
||||
const detectorsPayload = await detectorsRes.json()
|
||||
const objectsPayload = await objectsRes.json()
|
||||
|
||||
const titleToIdMap: Record<string, string> = {}
|
||||
if (Array.isArray(objectsPayload)) {
|
||||
for (const obj of objectsPayload) {
|
||||
if (obj && typeof obj.title === 'string' && typeof obj.id === 'number') {
|
||||
titleToIdMap[obj.title] = `object_${obj.id}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const statusToColor: Record<string, string> = {
|
||||
critical: statusColors.STATUS_COLOR_CRITICAL,
|
||||
warning: statusColors.STATUS_COLOR_WARNING,
|
||||
normal: statusColors.STATUS_COLOR_NORMAL,
|
||||
}
|
||||
|
||||
const transformedDetectors: Record<string, any> = {}
|
||||
const detectorsObj = detectorsPayload?.detectors ?? {}
|
||||
for (const [key, sensor] of Object.entries<any>(detectorsObj)) {
|
||||
const color = statusToColor[sensor.status] ?? statusColors.STATUS_COLOR_NORMAL
|
||||
const objectId = titleToIdMap[sensor.object] || sensor.object
|
||||
transformedDetectors[key] = {
|
||||
...sensor,
|
||||
status: color,
|
||||
object: objectId,
|
||||
checked: sensor.checked ?? false,
|
||||
location: sensor.zone ?? '',
|
||||
serial_number: sensor.serial_number ?? sensor.name ?? '',
|
||||
detector_type: sensor.detector_type ?? '',
|
||||
notifications: Array.isArray(sensor.notifications) ? sensor.notifications.map((n: any) => {
|
||||
const severity = String(n?.severity || n?.type || '').toLowerCase()
|
||||
|
||||
// Логируем оригинальные данные для отладки
|
||||
if (sensor.serial_number === 'GLE-1') {
|
||||
console.log('[get-detectors] Original notification for GLE-1:', { severity: n?.severity, type: n?.type, message: n?.message })
|
||||
}
|
||||
|
||||
// Добавляем поддержку русских названий
|
||||
let type = 'info'
|
||||
if (severity === 'critical' || severity === 'критический' || severity === 'критичный') {
|
||||
type = 'critical'
|
||||
} else if (severity === 'warning' || severity === 'предупреждение') {
|
||||
type = 'warning'
|
||||
}
|
||||
|
||||
const priority = type === 'critical' ? 'high' : type === 'warning' ? 'medium' : 'low'
|
||||
return {
|
||||
id: n.id,
|
||||
type,
|
||||
message: n.message,
|
||||
timestamp: n.timestamp || n.created_at,
|
||||
acknowledged: typeof n.acknowledged === 'boolean' ? n.acknowledged : !!n.resolved,
|
||||
priority,
|
||||
}
|
||||
}) : []
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { detectors: transformedDetectors },
|
||||
objectsCount: Array.isArray(objectsPayload) ? objectsPayload.length : 0,
|
||||
detectorsCount: Object.keys(transformedDetectors).length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching detectors data:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch detectors data',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ interface AreaChartProps {
|
||||
const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
||||
const width = 635
|
||||
const height = 280
|
||||
const margin = { top: 20, right: 30, bottom: 50, left: 60 }
|
||||
const margin = { top: 40, right: 30, bottom: 50, left: 60 }
|
||||
const plotWidth = width - margin.left - margin.right
|
||||
const plotHeight = height - margin.top - margin.bottom
|
||||
const baselineY = margin.top + plotHeight
|
||||
@@ -222,15 +222,15 @@ const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Легенда */}
|
||||
<g transform={`translate(${width - margin.right - 120}, ${margin.top})`}>
|
||||
{/* Легенда - горизонтально над графиком */}
|
||||
<g transform={`translate(${margin.left + plotWidth / 2 - 120}, 10)`}>
|
||||
<circle cx="6" cy="6" r="4" fill="#ef4444" stroke="rgb(15, 23, 42)" strokeWidth="2" />
|
||||
<text x="18" y="10" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||
Критические
|
||||
</text>
|
||||
|
||||
<circle cx="6" cy="24" r="4" fill="#fb923c" stroke="rgb(15, 23, 42)" strokeWidth="2" />
|
||||
<text x="18" y="28" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||
<circle cx="126" cy="6" r="4" fill="#fb923c" stroke="rgb(15, 23, 42)" strokeWidth="2" />
|
||||
<text x="138" y="10" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||
Предупреждения
|
||||
</text>
|
||||
</g>
|
||||
|
||||
@@ -16,7 +16,7 @@ interface BarChartProps {
|
||||
const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
||||
const width = 635
|
||||
const height = 280
|
||||
const margin = { top: 20, right: 30, bottom: 50, left: 60 }
|
||||
const margin = { top: 40, right: 30, bottom: 50, left: 60 }
|
||||
const plotWidth = width - margin.left - margin.right
|
||||
const plotHeight = height - margin.top - margin.bottom
|
||||
const baselineY = margin.top + plotHeight
|
||||
@@ -240,15 +240,15 @@ const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Легенда */}
|
||||
<g transform={`translate(${width - margin.right - 120}, ${margin.top})`}>
|
||||
{/* Легенда - горизонтально над графиком */}
|
||||
<g transform={`translate(${margin.left + plotWidth / 2 - 120}, 10)`}>
|
||||
<rect x="0" y="0" width="12" height="12" fill="#ef4444" rx="2" />
|
||||
<text x="18" y="10" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||
Критические
|
||||
</text>
|
||||
|
||||
<rect x="0" y="18" width="12" height="12" fill="#fb923c" rx="2" />
|
||||
<text x="18" y="28" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||
<rect x="120" y="0" width="12" height="12" fill="#fb923c" rx="2" />
|
||||
<text x="138" y="10" fontSize="11" fill="rgb(148, 163, 184)" fontFamily="Arial, sans-serif">
|
||||
Предупреждения
|
||||
</text>
|
||||
</g>
|
||||
|
||||
@@ -236,7 +236,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
|
||||
// Find the mesh for this sensor
|
||||
const allMeshes = importedMeshesRef.current || []
|
||||
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||
const sensorMeshes = collectSensorMeshes(allMeshes, sensorStatusMap)
|
||||
const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
|
||||
|
||||
if (!targetMesh) {
|
||||
@@ -571,7 +571,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
}
|
||||
|
||||
const allMeshes = importedMeshesRef.current || []
|
||||
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||
const sensorMeshes = collectSensorMeshes(allMeshes, sensorStatusMap)
|
||||
if (sensorMeshes.length === 0) {
|
||||
setAllSensorsOverlayCircles([])
|
||||
return
|
||||
@@ -609,14 +609,26 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||
// Сначала найдём ВСЕ датчики в 3D модели (без фильтра)
|
||||
const allSensorMeshesInModel = collectSensorMeshes(allMeshes, null)
|
||||
const allSensorIdsInModel = allSensorMeshesInModel.map(m => getSensorIdFromMesh(m)).filter(Boolean)
|
||||
|
||||
// Теперь применим фильтр по sensorStatusMap
|
||||
const sensorMeshes = collectSensorMeshes(allMeshes, sensorStatusMap)
|
||||
const filteredSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean)
|
||||
|
||||
console.log('[ModelViewer] Total meshes in model:', allMeshes.length)
|
||||
console.log('[ModelViewer] Sensor meshes found:', sensorMeshes.length)
|
||||
console.log('[ModelViewer] ALL sensor meshes in 3D model (unfiltered):', allSensorIdsInModel.length, allSensorIdsInModel)
|
||||
console.log('[ModelViewer] sensorStatusMap keys count:', sensorStatusMap ? Object.keys(sensorStatusMap).length : 0)
|
||||
console.log('[ModelViewer] Sensor meshes found (filtered by sensorStatusMap):', sensorMeshes.length, filteredSensorIds)
|
||||
|
||||
// Log first 5 sensor IDs found in meshes
|
||||
const sensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean).slice(0, 5)
|
||||
console.log('[ModelViewer] Sample sensor IDs from meshes:', sensorIds)
|
||||
// Найдём датчики которые есть в sensorStatusMap но НЕТ в 3D модели
|
||||
if (sensorStatusMap) {
|
||||
const missingInModel = Object.keys(sensorStatusMap).filter(id => !allSensorIdsInModel.includes(id))
|
||||
if (missingInModel.length > 0) {
|
||||
console.warn('[ModelViewer] Sensors in sensorStatusMap but MISSING in 3D model:', missingInModel.length, missingInModel.slice(0, 10))
|
||||
}
|
||||
}
|
||||
|
||||
if (sensorMeshes.length === 0) {
|
||||
console.warn('[ModelViewer] No sensor meshes found in 3D model!')
|
||||
@@ -670,7 +682,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||
const sensorMeshes = collectSensorMeshes(allMeshes, sensorStatusMap)
|
||||
const allSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m))
|
||||
const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
|
||||
|
||||
|
||||
@@ -20,11 +20,13 @@ import {
|
||||
PointerEventTypes,
|
||||
PointerInfo,
|
||||
Matrix,
|
||||
Ray,
|
||||
} from '@babylonjs/core'
|
||||
import '@babylonjs/loaders'
|
||||
|
||||
import SceneToolbar from './SceneToolbar';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner'
|
||||
import useNavigationStore from '@/app/store/navigationStore'
|
||||
import {
|
||||
getSensorIdFromMesh,
|
||||
collectSensorMeshes,
|
||||
@@ -67,6 +69,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
onSensorPick,
|
||||
highlightAllSensors,
|
||||
sensorStatusMap,
|
||||
showStats = false,
|
||||
onToggleStats,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const engineRef = useRef<Nullable<Engine>>(null)
|
||||
@@ -339,7 +343,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
|
||||
let engine: Engine
|
||||
try {
|
||||
engine = new Engine(canvas, true, { stencil: true })
|
||||
// Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU
|
||||
engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const message = `WebGL недоступен: ${errorMessage}`
|
||||
@@ -361,6 +366,9 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
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)
|
||||
@@ -688,16 +696,65 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
const maxDimension = Math.max(size.x, size.y, size.z)
|
||||
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
|
||||
|
||||
// Простое позиционирование камеры - всегда поворачиваемся к датчику
|
||||
console.log('[ModelViewer] Calculating camera direction to sensor')
|
||||
|
||||
// Вычисляем направление от текущей позиции камеры к датчику
|
||||
const directionToSensor = center.subtract(camera.position).normalize()
|
||||
|
||||
// Преобразуем в сферические координаты
|
||||
// alpha - горизонтальный угол (вокруг оси Y)
|
||||
let targetAlpha = Math.atan2(directionToSensor.x, directionToSensor.z)
|
||||
|
||||
// beta - вертикальный угол (от вертикали)
|
||||
// Используем оптимальный угол 60° для обзора
|
||||
let targetBeta = Math.PI / 3 // 60°
|
||||
|
||||
console.log('[ModelViewer] Calculated camera direction:', {
|
||||
alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°',
|
||||
beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°',
|
||||
sensorPosition: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) },
|
||||
cameraPosition: { x: camera.position.x.toFixed(2), y: camera.position.y.toFixed(2), z: camera.position.z.toFixed(2) }
|
||||
})
|
||||
|
||||
// Нормализуем alpha в диапазон [-PI, PI]
|
||||
while (targetAlpha > Math.PI) targetAlpha -= 2 * Math.PI
|
||||
while (targetAlpha < -Math.PI) targetAlpha += 2 * Math.PI
|
||||
|
||||
// Ограничиваем beta в разумных пределах
|
||||
targetBeta = Math.max(0.1, Math.min(Math.PI - 0.1, targetBeta))
|
||||
|
||||
scene.stopAnimation(camera)
|
||||
|
||||
// Логирование перед анимацией
|
||||
console.log('[ModelViewer] Starting camera animation:', {
|
||||
sensorId,
|
||||
from: {
|
||||
target: { x: camera.target.x.toFixed(2), y: camera.target.y.toFixed(2), z: camera.target.z.toFixed(2) },
|
||||
radius: camera.radius.toFixed(2),
|
||||
alpha: (camera.alpha * 180 / Math.PI).toFixed(1) + '°',
|
||||
beta: (camera.beta * 180 / Math.PI).toFixed(1) + '°'
|
||||
},
|
||||
to: {
|
||||
target: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) },
|
||||
radius: targetRadius.toFixed(2),
|
||||
alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°',
|
||||
beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°'
|
||||
},
|
||||
alphaChange: ((targetAlpha - camera.alpha) * 180 / Math.PI).toFixed(1) + '°',
|
||||
betaChange: ((targetBeta - camera.beta) * 180 / Math.PI).toFixed(1) + '°'
|
||||
})
|
||||
|
||||
const ease = new CubicEase()
|
||||
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
||||
const frameRate = 60
|
||||
const durationMs = 600
|
||||
const durationMs = 800
|
||||
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||
|
||||
Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
Animation.CreateAndStartAnimation('camAlpha', camera, 'alpha', frameRate, totalFrames, camera.alpha, targetAlpha, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
Animation.CreateAndStartAnimation('camBeta', camera, 'beta', frameRate, totalFrames, camera.beta, targetBeta, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
|
||||
applyHighlightToMeshes(
|
||||
highlightLayerRef.current,
|
||||
@@ -862,7 +919,10 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
onPan={handlePan}
|
||||
onSelectModel={onSelectModel}
|
||||
panActive={panActive}
|
||||
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
|
||||
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
{/* UPDATED: Interactive overlay circles with hover effects */}
|
||||
@@ -908,4 +968,4 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelViewer
|
||||
export default React.memo(ModelViewer)
|
||||
|
||||
@@ -1,971 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
Engine,
|
||||
Scene,
|
||||
Vector3,
|
||||
HemisphericLight,
|
||||
ArcRotateCamera,
|
||||
Color3,
|
||||
Color4,
|
||||
AbstractMesh,
|
||||
Nullable,
|
||||
HighlightLayer,
|
||||
Animation,
|
||||
CubicEase,
|
||||
EasingFunction,
|
||||
ImportMeshAsync,
|
||||
PointerEventTypes,
|
||||
PointerInfo,
|
||||
Matrix,
|
||||
Ray,
|
||||
} from '@babylonjs/core'
|
||||
import '@babylonjs/loaders'
|
||||
|
||||
import SceneToolbar from './SceneToolbar';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner'
|
||||
import useNavigationStore from '@/app/store/navigationStore'
|
||||
import {
|
||||
getSensorIdFromMesh,
|
||||
collectSensorMeshes,
|
||||
applyHighlightToMeshes,
|
||||
statusToColor3,
|
||||
} from './sensorHighlight'
|
||||
import {
|
||||
computeSensorOverlayCircles,
|
||||
hexWithAlpha,
|
||||
} from './sensorHighlightOverlay'
|
||||
|
||||
export interface ModelViewerProps {
|
||||
modelPath: string
|
||||
onSelectModel: (path: string) => void;
|
||||
onModelLoaded?: (modelData: {
|
||||
meshes: AbstractMesh[]
|
||||
boundingBox: {
|
||||
min: { x: number; y: number; z: number }
|
||||
max: { x: number; y: number; z: number }
|
||||
}
|
||||
}) => void
|
||||
onError?: (error: string) => void
|
||||
activeMenu?: string | null
|
||||
focusSensorId?: string | null
|
||||
renderOverlay?: (params: { anchor: { left: number; top: number } | null; info?: { name?: string; sensorId?: string } | null }) => React.ReactNode
|
||||
isSensorSelectionEnabled?: boolean
|
||||
onSensorPick?: (sensorId: string | null) => void
|
||||
highlightAllSensors?: boolean
|
||||
sensorStatusMap?: Record<string, string>
|
||||
}
|
||||
|
||||
const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
modelPath,
|
||||
onSelectModel,
|
||||
onModelLoaded,
|
||||
onError,
|
||||
focusSensorId,
|
||||
renderOverlay,
|
||||
isSensorSelectionEnabled,
|
||||
onSensorPick,
|
||||
highlightAllSensors,
|
||||
sensorStatusMap,
|
||||
showStats = false,
|
||||
onToggleStats,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const engineRef = useRef<Nullable<Engine>>(null)
|
||||
const sceneRef = useRef<Nullable<Scene>>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||
const [showModel, setShowModel] = useState(false)
|
||||
const isInitializedRef = useRef(false)
|
||||
const isDisposedRef = useRef(false)
|
||||
const importedMeshesRef = useRef<AbstractMesh[]>([])
|
||||
const highlightLayerRef = useRef<HighlightLayer | null>(null)
|
||||
const highlightedMeshesRef = useRef<AbstractMesh[]>([])
|
||||
const chosenMeshRef = useRef<AbstractMesh | null>(null)
|
||||
const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null)
|
||||
const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null)
|
||||
const [modelReady, setModelReady] = useState(false)
|
||||
const [panActive, setPanActive] = useState(false);
|
||||
const [webglError, setWebglError] = useState<string | null>(null)
|
||||
const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState<
|
||||
{ sensorId: string; left: number; top: number; colorHex: string }[]
|
||||
>([])
|
||||
// NEW: State for tracking hovered sensor in overlay circles
|
||||
const [hoveredSensorId, setHoveredSensorId] = useState<string | null>(null)
|
||||
|
||||
const handlePan = () => setPanActive(!panActive);
|
||||
|
||||
useEffect(() => {
|
||||
const scene = sceneRef.current;
|
||||
const camera = scene?.activeCamera as ArcRotateCamera;
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
if (!scene || !camera || !canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
let observer: any = null;
|
||||
|
||||
if (panActive) {
|
||||
camera.detachControl();
|
||||
|
||||
observer = scene.onPointerObservable.add((pointerInfo: PointerInfo) => {
|
||||
const evt = pointerInfo.event;
|
||||
|
||||
if (evt.buttons === 1) {
|
||||
camera.inertialPanningX -= evt.movementX / camera.panningSensibility;
|
||||
camera.inertialPanningY += evt.movementY / camera.panningSensibility;
|
||||
}
|
||||
else if (evt.buttons === 2) {
|
||||
camera.inertialAlphaOffset -= evt.movementX / camera.angularSensibilityX;
|
||||
camera.inertialBetaOffset -= evt.movementY / camera.angularSensibilityY;
|
||||
}
|
||||
}, PointerEventTypes.POINTERMOVE);
|
||||
|
||||
} else {
|
||||
camera.detachControl();
|
||||
camera.attachControl(canvas, true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observer) {
|
||||
scene.onPointerObservable.remove(observer);
|
||||
}
|
||||
if (!camera.isDisposed() && !camera.inputs.attachedToElement) {
|
||||
camera.attachControl(canvas, true);
|
||||
}
|
||||
};
|
||||
}, [panActive, sceneRef, canvasRef]);
|
||||
|
||||
const handleZoomIn = () => {
|
||||
const camera = sceneRef.current?.activeCamera as ArcRotateCamera
|
||||
if (camera) {
|
||||
sceneRef.current?.stopAnimation(camera)
|
||||
const ease = new CubicEase()
|
||||
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT)
|
||||
|
||||
const frameRate = 60
|
||||
const durationMs = 300
|
||||
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||
|
||||
const currentRadius = camera.radius
|
||||
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 0.1, currentRadius * 0.8)
|
||||
|
||||
Animation.CreateAndStartAnimation(
|
||||
'zoomIn',
|
||||
camera,
|
||||
'radius',
|
||||
frameRate,
|
||||
totalFrames,
|
||||
currentRadius,
|
||||
targetRadius,
|
||||
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||
ease
|
||||
)
|
||||
}
|
||||
}
|
||||
const handleZoomOut = () => {
|
||||
const camera = sceneRef.current?.activeCamera as ArcRotateCamera
|
||||
if (camera) {
|
||||
sceneRef.current?.stopAnimation(camera)
|
||||
const ease = new CubicEase()
|
||||
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT)
|
||||
|
||||
const frameRate = 60
|
||||
const durationMs = 300
|
||||
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||
|
||||
const currentRadius = camera.radius
|
||||
const targetRadius = Math.min(camera.upperRadiusLimit ?? Infinity, currentRadius * 1.2)
|
||||
|
||||
Animation.CreateAndStartAnimation(
|
||||
'zoomOut',
|
||||
camera,
|
||||
'radius',
|
||||
frameRate,
|
||||
totalFrames,
|
||||
currentRadius,
|
||||
targetRadius,
|
||||
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||
ease
|
||||
)
|
||||
}
|
||||
}
|
||||
const handleTopView = () => {
|
||||
const camera = sceneRef.current?.activeCamera as ArcRotateCamera;
|
||||
if (camera) {
|
||||
sceneRef.current?.stopAnimation(camera);
|
||||
const ease = new CubicEase();
|
||||
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT);
|
||||
|
||||
const frameRate = 60;
|
||||
const durationMs = 500;
|
||||
const totalFrames = Math.round((durationMs / 1000) * frameRate);
|
||||
|
||||
Animation.CreateAndStartAnimation(
|
||||
'topViewAlpha',
|
||||
camera,
|
||||
'alpha',
|
||||
frameRate,
|
||||
totalFrames,
|
||||
camera.alpha,
|
||||
Math.PI / 2,
|
||||
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||
ease
|
||||
);
|
||||
|
||||
Animation.CreateAndStartAnimation(
|
||||
'topViewBeta',
|
||||
camera,
|
||||
'beta',
|
||||
frameRate,
|
||||
totalFrames,
|
||||
camera.beta,
|
||||
0,
|
||||
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||
ease
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// NEW: Function to handle overlay circle click
|
||||
const handleOverlayCircleClick = (sensorId: string) => {
|
||||
console.log('[ModelViewer] Overlay circle clicked:', sensorId)
|
||||
|
||||
// Find the mesh for this sensor
|
||||
const allMeshes = importedMeshesRef.current || []
|
||||
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||
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)
|
||||
|
||||
// Простое позиционирование камеры - всегда поворачиваемся к датчику
|
||||
console.log('[ModelViewer] Calculating camera direction to sensor')
|
||||
|
||||
// Вычисляем направление от текущей позиции камеры к датчику
|
||||
const directionToSensor = center.subtract(camera.position).normalize()
|
||||
|
||||
// Преобразуем в сферические координаты
|
||||
// alpha - горизонтальный угол (вокруг оси Y)
|
||||
let targetAlpha = Math.atan2(directionToSensor.x, directionToSensor.z)
|
||||
|
||||
// beta - вертикальный угол (от вертикали)
|
||||
// Используем оптимальный угол 60° для обзора
|
||||
let targetBeta = Math.PI / 3 // 60°
|
||||
|
||||
console.log('[ModelViewer] Calculated camera direction:', {
|
||||
alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°',
|
||||
beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°',
|
||||
sensorPosition: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) },
|
||||
cameraPosition: { x: camera.position.x.toFixed(2), y: camera.position.y.toFixed(2), z: camera.position.z.toFixed(2) }
|
||||
})
|
||||
|
||||
// Нормализуем alpha в диапазон [-PI, PI]
|
||||
while (targetAlpha > Math.PI) targetAlpha -= 2 * Math.PI
|
||||
while (targetAlpha < -Math.PI) targetAlpha += 2 * Math.PI
|
||||
|
||||
// Ограничиваем beta в разумных пределах
|
||||
targetBeta = Math.max(0.1, Math.min(Math.PI - 0.1, targetBeta))
|
||||
|
||||
scene.stopAnimation(camera)
|
||||
|
||||
// Логирование перед анимацией
|
||||
console.log('[ModelViewer] Starting camera animation:', {
|
||||
sensorId,
|
||||
from: {
|
||||
target: { x: camera.target.x.toFixed(2), y: camera.target.y.toFixed(2), z: camera.target.z.toFixed(2) },
|
||||
radius: camera.radius.toFixed(2),
|
||||
alpha: (camera.alpha * 180 / Math.PI).toFixed(1) + '°',
|
||||
beta: (camera.beta * 180 / Math.PI).toFixed(1) + '°'
|
||||
},
|
||||
to: {
|
||||
target: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) },
|
||||
radius: targetRadius.toFixed(2),
|
||||
alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°',
|
||||
beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°'
|
||||
},
|
||||
alphaChange: ((targetAlpha - camera.alpha) * 180 / Math.PI).toFixed(1) + '°',
|
||||
betaChange: ((targetBeta - camera.beta) * 180 / Math.PI).toFixed(1) + '°'
|
||||
})
|
||||
|
||||
const ease = new CubicEase()
|
||||
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
||||
const frameRate = 60
|
||||
const durationMs = 800
|
||||
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||
|
||||
Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
Animation.CreateAndStartAnimation('camAlpha', camera, 'alpha', frameRate, totalFrames, camera.alpha, targetAlpha, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
Animation.CreateAndStartAnimation('camBeta', camera, 'beta', frameRate, totalFrames, camera.beta, targetBeta, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
|
||||
applyHighlightToMeshes(
|
||||
highlightLayerRef.current,
|
||||
highlightedMeshesRef,
|
||||
[chosen],
|
||||
mesh => {
|
||||
const sid = getSensorIdFromMesh(mesh)
|
||||
const status = sid ? sensorStatusMap?.[sid] : undefined
|
||||
return statusToColor3(status ?? null)
|
||||
},
|
||||
)
|
||||
chosenMeshRef.current = chosen
|
||||
setOverlayData({ name: chosen.name, sensorId })
|
||||
} catch {
|
||||
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||
highlightedMeshesRef.current = []
|
||||
highlightLayerRef.current?.removeAllMeshes()
|
||||
chosenMeshRef.current = null
|
||||
setOverlayPos(null)
|
||||
setOverlayData(null)
|
||||
}
|
||||
} else {
|
||||
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||
highlightedMeshesRef.current = []
|
||||
highlightLayerRef.current?.removeAllMeshes()
|
||||
chosenMeshRef.current = null
|
||||
setOverlayPos(null)
|
||||
setOverlayData(null)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [focusSensorId, modelReady, highlightAllSensors])
|
||||
|
||||
useEffect(() => {
|
||||
const scene = sceneRef.current
|
||||
if (!scene || !modelReady || !isSensorSelectionEnabled) return
|
||||
|
||||
const pickObserver = scene.onPointerObservable.add((pointerInfo: PointerInfo) => {
|
||||
if (pointerInfo.type !== PointerEventTypes.POINTERPICK) return
|
||||
const pick = pointerInfo.pickInfo
|
||||
if (!pick || !pick.hit) {
|
||||
onSensorPick?.(null)
|
||||
return
|
||||
}
|
||||
|
||||
const pickedMesh = pick.pickedMesh
|
||||
const sensorId = getSensorIdFromMesh(pickedMesh)
|
||||
|
||||
if (sensorId) {
|
||||
onSensorPick?.(sensorId)
|
||||
} else {
|
||||
onSensorPick?.(null)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
scene.onPointerObservable.remove(pickObserver)
|
||||
}
|
||||
}, [modelReady, isSensorSelectionEnabled, onSensorPick])
|
||||
|
||||
const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => {
|
||||
if (!sceneRef.current || !mesh) return null
|
||||
const scene = sceneRef.current
|
||||
try {
|
||||
const bbox = (typeof mesh.getHierarchyBoundingVectors === 'function')
|
||||
? mesh.getHierarchyBoundingVectors()
|
||||
: { min: mesh.getBoundingInfo().boundingBox.minimumWorld, max: mesh.getBoundingInfo().boundingBox.maximumWorld }
|
||||
const center = bbox.min.add(bbox.max).scale(0.5)
|
||||
|
||||
const viewport = scene.activeCamera?.viewport.toGlobal(engineRef.current!.getRenderWidth(), engineRef.current!.getRenderHeight())
|
||||
if (!viewport) return null
|
||||
|
||||
const projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport)
|
||||
if (!projected) return null
|
||||
|
||||
return { left: projected.x, top: projected.y }
|
||||
} catch (error) {
|
||||
console.error('[ModelViewer] Error computing overlay position:', error)
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!chosenMeshRef.current || !overlayData) return
|
||||
const pos = computeOverlayPosition(chosenMeshRef.current)
|
||||
setOverlayPos(pos)
|
||||
}, [overlayData, computeOverlayPosition])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sceneRef.current || !chosenMeshRef.current || !overlayData) return
|
||||
const scene = sceneRef.current
|
||||
|
||||
const updateOverlayPosition = () => {
|
||||
const pos = computeOverlayPosition(chosenMeshRef.current)
|
||||
setOverlayPos(pos)
|
||||
}
|
||||
scene.registerBeforeRender(updateOverlayPosition)
|
||||
return () => scene.unregisterBeforeRender(updateOverlayPosition)
|
||||
}, [overlayData, computeOverlayPosition])
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
||||
{!modelPath ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
||||
<div className="text-amber-400 text-lg font-semibold mb-2">
|
||||
3D модель не выбрана
|
||||
</div>
|
||||
<div className="text-gray-300 mb-4">
|
||||
Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`w-full h-full outline-none block transition-opacity duration-500 ${
|
||||
showModel && !webglError ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
{webglError ? (
|
||||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
||||
<div className="text-red-400 text-lg font-semibold mb-2">
|
||||
3D просмотр недоступен
|
||||
</div>
|
||||
<div className="text-gray-300 mb-4">
|
||||
{webglError}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
||||
<LoadingSpinner
|
||||
progress={loadingProgress}
|
||||
size={120}
|
||||
strokeWidth={8}
|
||||
/>
|
||||
</div>
|
||||
) : !modelReady ? (
|
||||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-40">
|
||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
||||
<div className="text-gray-400 text-lg font-semibold mb-4">
|
||||
3D модель не загружена
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Модель не готова к отображению
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<SceneToolbar
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onTopView={handleTopView}
|
||||
onPan={handlePan}
|
||||
onSelectModel={onSelectModel}
|
||||
panActive={panActive}
|
||||
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
|
||||
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
{/* UPDATED: Interactive overlay circles with hover effects */}
|
||||
{allSensorsOverlayCircles.map(circle => {
|
||||
const size = 36
|
||||
const radius = size / 2
|
||||
const fill = hexWithAlpha(circle.colorHex, 0.2)
|
||||
const isHovered = hoveredSensorId === circle.sensorId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${circle.sensorId}-${Math.round(circle.left)}-${Math.round(circle.top)}`}
|
||||
onClick={() => handleOverlayCircleClick(circle.sensorId)}
|
||||
onMouseEnter={() => setHoveredSensorId(circle.sensorId)}
|
||||
onMouseLeave={() => setHoveredSensorId(null)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: circle.left - radius,
|
||||
top: circle.top - radius,
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '9999px',
|
||||
border: `2px solid ${circle.colorHex}`,
|
||||
backgroundColor: fill,
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
transform: isHovered ? 'scale(1.4)' : 'scale(1)',
|
||||
boxShadow: isHovered
|
||||
? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}`
|
||||
: `0 0 8px ${circle.colorHex}`,
|
||||
zIndex: isHovered ? 50 : 10,
|
||||
}}
|
||||
title={`Датчик: ${circle.sensorId}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{renderOverlay && overlayPos && overlayData
|
||||
? renderOverlay({ anchor: overlayPos, info: overlayData })
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelViewer
|
||||
@@ -1,971 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
Engine,
|
||||
Scene,
|
||||
Vector3,
|
||||
HemisphericLight,
|
||||
ArcRotateCamera,
|
||||
Color3,
|
||||
Color4,
|
||||
AbstractMesh,
|
||||
Nullable,
|
||||
HighlightLayer,
|
||||
Animation,
|
||||
CubicEase,
|
||||
EasingFunction,
|
||||
ImportMeshAsync,
|
||||
PointerEventTypes,
|
||||
PointerInfo,
|
||||
Matrix,
|
||||
Ray,
|
||||
} from '@babylonjs/core'
|
||||
import '@babylonjs/loaders'
|
||||
|
||||
import SceneToolbar from './SceneToolbar';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner'
|
||||
import useNavigationStore from '@/app/store/navigationStore'
|
||||
import {
|
||||
getSensorIdFromMesh,
|
||||
collectSensorMeshes,
|
||||
applyHighlightToMeshes,
|
||||
statusToColor3,
|
||||
} from './sensorHighlight'
|
||||
import {
|
||||
computeSensorOverlayCircles,
|
||||
hexWithAlpha,
|
||||
} from './sensorHighlightOverlay'
|
||||
|
||||
export interface ModelViewerProps {
|
||||
modelPath: string
|
||||
onSelectModel: (path: string) => void;
|
||||
onModelLoaded?: (modelData: {
|
||||
meshes: AbstractMesh[]
|
||||
boundingBox: {
|
||||
min: { x: number; y: number; z: number }
|
||||
max: { x: number; y: number; z: number }
|
||||
}
|
||||
}) => void
|
||||
onError?: (error: string) => void
|
||||
activeMenu?: string | null
|
||||
focusSensorId?: string | null
|
||||
renderOverlay?: (params: { anchor: { left: number; top: number } | null; info?: { name?: string; sensorId?: string } | null }) => React.ReactNode
|
||||
isSensorSelectionEnabled?: boolean
|
||||
onSensorPick?: (sensorId: string | null) => void
|
||||
highlightAllSensors?: boolean
|
||||
sensorStatusMap?: Record<string, string>
|
||||
}
|
||||
|
||||
const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
modelPath,
|
||||
onSelectModel,
|
||||
onModelLoaded,
|
||||
onError,
|
||||
focusSensorId,
|
||||
renderOverlay,
|
||||
isSensorSelectionEnabled,
|
||||
onSensorPick,
|
||||
highlightAllSensors,
|
||||
sensorStatusMap,
|
||||
showStats = false,
|
||||
onToggleStats,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const engineRef = useRef<Nullable<Engine>>(null)
|
||||
const sceneRef = useRef<Nullable<Scene>>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||
const [showModel, setShowModel] = useState(false)
|
||||
const isInitializedRef = useRef(false)
|
||||
const isDisposedRef = useRef(false)
|
||||
const importedMeshesRef = useRef<AbstractMesh[]>([])
|
||||
const highlightLayerRef = useRef<HighlightLayer | null>(null)
|
||||
const highlightedMeshesRef = useRef<AbstractMesh[]>([])
|
||||
const chosenMeshRef = useRef<AbstractMesh | null>(null)
|
||||
const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null)
|
||||
const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null)
|
||||
const [modelReady, setModelReady] = useState(false)
|
||||
const [panActive, setPanActive] = useState(false);
|
||||
const [webglError, setWebglError] = useState<string | null>(null)
|
||||
const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState<
|
||||
{ sensorId: string; left: number; top: number; colorHex: string }[]
|
||||
>([])
|
||||
// NEW: State for tracking hovered sensor in overlay circles
|
||||
const [hoveredSensorId, setHoveredSensorId] = useState<string | null>(null)
|
||||
|
||||
const handlePan = () => setPanActive(!panActive);
|
||||
|
||||
useEffect(() => {
|
||||
const scene = sceneRef.current;
|
||||
const camera = scene?.activeCamera as ArcRotateCamera;
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
if (!scene || !camera || !canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
let observer: any = null;
|
||||
|
||||
if (panActive) {
|
||||
camera.detachControl();
|
||||
|
||||
observer = scene.onPointerObservable.add((pointerInfo: PointerInfo) => {
|
||||
const evt = pointerInfo.event;
|
||||
|
||||
if (evt.buttons === 1) {
|
||||
camera.inertialPanningX -= evt.movementX / camera.panningSensibility;
|
||||
camera.inertialPanningY += evt.movementY / camera.panningSensibility;
|
||||
}
|
||||
else if (evt.buttons === 2) {
|
||||
camera.inertialAlphaOffset -= evt.movementX / camera.angularSensibilityX;
|
||||
camera.inertialBetaOffset -= evt.movementY / camera.angularSensibilityY;
|
||||
}
|
||||
}, PointerEventTypes.POINTERMOVE);
|
||||
|
||||
} else {
|
||||
camera.detachControl();
|
||||
camera.attachControl(canvas, true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observer) {
|
||||
scene.onPointerObservable.remove(observer);
|
||||
}
|
||||
if (!camera.isDisposed() && !camera.inputs.attachedToElement) {
|
||||
camera.attachControl(canvas, true);
|
||||
}
|
||||
};
|
||||
}, [panActive, sceneRef, canvasRef]);
|
||||
|
||||
const handleZoomIn = () => {
|
||||
const camera = sceneRef.current?.activeCamera as ArcRotateCamera
|
||||
if (camera) {
|
||||
sceneRef.current?.stopAnimation(camera)
|
||||
const ease = new CubicEase()
|
||||
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT)
|
||||
|
||||
const frameRate = 60
|
||||
const durationMs = 300
|
||||
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||
|
||||
const currentRadius = camera.radius
|
||||
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 0.1, currentRadius * 0.8)
|
||||
|
||||
Animation.CreateAndStartAnimation(
|
||||
'zoomIn',
|
||||
camera,
|
||||
'radius',
|
||||
frameRate,
|
||||
totalFrames,
|
||||
currentRadius,
|
||||
targetRadius,
|
||||
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||
ease
|
||||
)
|
||||
}
|
||||
}
|
||||
const handleZoomOut = () => {
|
||||
const camera = sceneRef.current?.activeCamera as ArcRotateCamera
|
||||
if (camera) {
|
||||
sceneRef.current?.stopAnimation(camera)
|
||||
const ease = new CubicEase()
|
||||
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT)
|
||||
|
||||
const frameRate = 60
|
||||
const durationMs = 300
|
||||
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||
|
||||
const currentRadius = camera.radius
|
||||
const targetRadius = Math.min(camera.upperRadiusLimit ?? Infinity, currentRadius * 1.2)
|
||||
|
||||
Animation.CreateAndStartAnimation(
|
||||
'zoomOut',
|
||||
camera,
|
||||
'radius',
|
||||
frameRate,
|
||||
totalFrames,
|
||||
currentRadius,
|
||||
targetRadius,
|
||||
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||
ease
|
||||
)
|
||||
}
|
||||
}
|
||||
const handleTopView = () => {
|
||||
const camera = sceneRef.current?.activeCamera as ArcRotateCamera;
|
||||
if (camera) {
|
||||
sceneRef.current?.stopAnimation(camera);
|
||||
const ease = new CubicEase();
|
||||
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT);
|
||||
|
||||
const frameRate = 60;
|
||||
const durationMs = 500;
|
||||
const totalFrames = Math.round((durationMs / 1000) * frameRate);
|
||||
|
||||
Animation.CreateAndStartAnimation(
|
||||
'topViewAlpha',
|
||||
camera,
|
||||
'alpha',
|
||||
frameRate,
|
||||
totalFrames,
|
||||
camera.alpha,
|
||||
Math.PI / 2,
|
||||
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||
ease
|
||||
);
|
||||
|
||||
Animation.CreateAndStartAnimation(
|
||||
'topViewBeta',
|
||||
camera,
|
||||
'beta',
|
||||
frameRate,
|
||||
totalFrames,
|
||||
camera.beta,
|
||||
0,
|
||||
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||
ease
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// NEW: Function to handle overlay circle click
|
||||
const handleOverlayCircleClick = (sensorId: string) => {
|
||||
console.log('[ModelViewer] Overlay circle clicked:', sensorId)
|
||||
|
||||
// Find the mesh for this sensor
|
||||
const allMeshes = importedMeshesRef.current || []
|
||||
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||
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)
|
||||
|
||||
// Простое позиционирование камеры - всегда поворачиваемся к датчику
|
||||
console.log('[ModelViewer] Calculating camera direction to sensor')
|
||||
|
||||
// Вычисляем направление от текущей позиции камеры к датчику
|
||||
const directionToSensor = center.subtract(camera.position).normalize()
|
||||
|
||||
// Преобразуем в сферические координаты
|
||||
// alpha - горизонтальный угол (вокруг оси Y)
|
||||
let targetAlpha = Math.atan2(directionToSensor.x, directionToSensor.z)
|
||||
|
||||
// beta - вертикальный угол (от вертикали)
|
||||
// Используем оптимальный угол 60° для обзора
|
||||
let targetBeta = Math.PI / 3 // 60°
|
||||
|
||||
console.log('[ModelViewer] Calculated camera direction:', {
|
||||
alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°',
|
||||
beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°',
|
||||
sensorPosition: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) },
|
||||
cameraPosition: { x: camera.position.x.toFixed(2), y: camera.position.y.toFixed(2), z: camera.position.z.toFixed(2) }
|
||||
})
|
||||
|
||||
// Нормализуем alpha в диапазон [-PI, PI]
|
||||
while (targetAlpha > Math.PI) targetAlpha -= 2 * Math.PI
|
||||
while (targetAlpha < -Math.PI) targetAlpha += 2 * Math.PI
|
||||
|
||||
// Ограничиваем beta в разумных пределах
|
||||
targetBeta = Math.max(0.1, Math.min(Math.PI - 0.1, targetBeta))
|
||||
|
||||
scene.stopAnimation(camera)
|
||||
|
||||
// Логирование перед анимацией
|
||||
console.log('[ModelViewer] Starting camera animation:', {
|
||||
sensorId,
|
||||
from: {
|
||||
target: { x: camera.target.x.toFixed(2), y: camera.target.y.toFixed(2), z: camera.target.z.toFixed(2) },
|
||||
radius: camera.radius.toFixed(2),
|
||||
alpha: (camera.alpha * 180 / Math.PI).toFixed(1) + '°',
|
||||
beta: (camera.beta * 180 / Math.PI).toFixed(1) + '°'
|
||||
},
|
||||
to: {
|
||||
target: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) },
|
||||
radius: targetRadius.toFixed(2),
|
||||
alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°',
|
||||
beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°'
|
||||
},
|
||||
alphaChange: ((targetAlpha - camera.alpha) * 180 / Math.PI).toFixed(1) + '°',
|
||||
betaChange: ((targetBeta - camera.beta) * 180 / Math.PI).toFixed(1) + '°'
|
||||
})
|
||||
|
||||
const ease = new CubicEase()
|
||||
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
||||
const frameRate = 60
|
||||
const durationMs = 800
|
||||
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||
|
||||
Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
Animation.CreateAndStartAnimation('camAlpha', camera, 'alpha', frameRate, totalFrames, camera.alpha, targetAlpha, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
Animation.CreateAndStartAnimation('camBeta', camera, 'beta', frameRate, totalFrames, camera.beta, targetBeta, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
|
||||
applyHighlightToMeshes(
|
||||
highlightLayerRef.current,
|
||||
highlightedMeshesRef,
|
||||
[chosen],
|
||||
mesh => {
|
||||
const sid = getSensorIdFromMesh(mesh)
|
||||
const status = sid ? sensorStatusMap?.[sid] : undefined
|
||||
return statusToColor3(status ?? null)
|
||||
},
|
||||
)
|
||||
chosenMeshRef.current = chosen
|
||||
setOverlayData({ name: chosen.name, sensorId })
|
||||
} catch {
|
||||
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||
highlightedMeshesRef.current = []
|
||||
highlightLayerRef.current?.removeAllMeshes()
|
||||
chosenMeshRef.current = null
|
||||
setOverlayPos(null)
|
||||
setOverlayData(null)
|
||||
}
|
||||
} else {
|
||||
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||
highlightedMeshesRef.current = []
|
||||
highlightLayerRef.current?.removeAllMeshes()
|
||||
chosenMeshRef.current = null
|
||||
setOverlayPos(null)
|
||||
setOverlayData(null)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [focusSensorId, modelReady, highlightAllSensors])
|
||||
|
||||
useEffect(() => {
|
||||
const scene = sceneRef.current
|
||||
if (!scene || !modelReady || !isSensorSelectionEnabled) return
|
||||
|
||||
const pickObserver = scene.onPointerObservable.add((pointerInfo: PointerInfo) => {
|
||||
if (pointerInfo.type !== PointerEventTypes.POINTERPICK) return
|
||||
const pick = pointerInfo.pickInfo
|
||||
if (!pick || !pick.hit) {
|
||||
onSensorPick?.(null)
|
||||
return
|
||||
}
|
||||
|
||||
const pickedMesh = pick.pickedMesh
|
||||
const sensorId = getSensorIdFromMesh(pickedMesh)
|
||||
|
||||
if (sensorId) {
|
||||
onSensorPick?.(sensorId)
|
||||
} else {
|
||||
onSensorPick?.(null)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
scene.onPointerObservable.remove(pickObserver)
|
||||
}
|
||||
}, [modelReady, isSensorSelectionEnabled, onSensorPick])
|
||||
|
||||
const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => {
|
||||
if (!sceneRef.current || !mesh) return null
|
||||
const scene = sceneRef.current
|
||||
try {
|
||||
const bbox = (typeof mesh.getHierarchyBoundingVectors === 'function')
|
||||
? mesh.getHierarchyBoundingVectors()
|
||||
: { min: mesh.getBoundingInfo().boundingBox.minimumWorld, max: mesh.getBoundingInfo().boundingBox.maximumWorld }
|
||||
const center = bbox.min.add(bbox.max).scale(0.5)
|
||||
|
||||
const viewport = scene.activeCamera?.viewport.toGlobal(engineRef.current!.getRenderWidth(), engineRef.current!.getRenderHeight())
|
||||
if (!viewport) return null
|
||||
|
||||
const projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport)
|
||||
if (!projected) return null
|
||||
|
||||
return { left: projected.x, top: projected.y }
|
||||
} catch (error) {
|
||||
console.error('[ModelViewer] Error computing overlay position:', error)
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!chosenMeshRef.current || !overlayData) return
|
||||
const pos = computeOverlayPosition(chosenMeshRef.current)
|
||||
setOverlayPos(pos)
|
||||
}, [overlayData, computeOverlayPosition])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sceneRef.current || !chosenMeshRef.current || !overlayData) return
|
||||
const scene = sceneRef.current
|
||||
|
||||
const updateOverlayPosition = () => {
|
||||
const pos = computeOverlayPosition(chosenMeshRef.current)
|
||||
setOverlayPos(pos)
|
||||
}
|
||||
scene.registerBeforeRender(updateOverlayPosition)
|
||||
return () => scene.unregisterBeforeRender(updateOverlayPosition)
|
||||
}, [overlayData, computeOverlayPosition])
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
||||
{!modelPath ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
||||
<div className="text-amber-400 text-lg font-semibold mb-2">
|
||||
3D модель не выбрана
|
||||
</div>
|
||||
<div className="text-gray-300 mb-4">
|
||||
Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`w-full h-full outline-none block transition-opacity duration-500 ${
|
||||
showModel && !webglError ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
{webglError ? (
|
||||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
||||
<div className="text-red-400 text-lg font-semibold mb-2">
|
||||
3D просмотр недоступен
|
||||
</div>
|
||||
<div className="text-gray-300 mb-4">
|
||||
{webglError}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
||||
<LoadingSpinner
|
||||
progress={loadingProgress}
|
||||
size={120}
|
||||
strokeWidth={8}
|
||||
/>
|
||||
</div>
|
||||
) : !modelReady ? (
|
||||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-40">
|
||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
||||
<div className="text-gray-400 text-lg font-semibold mb-4">
|
||||
3D модель не загружена
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Модель не готова к отображению
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<SceneToolbar
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onTopView={handleTopView}
|
||||
onPan={handlePan}
|
||||
onSelectModel={onSelectModel}
|
||||
panActive={panActive}
|
||||
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
|
||||
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
{/* UPDATED: Interactive overlay circles with hover effects */}
|
||||
{allSensorsOverlayCircles.map(circle => {
|
||||
const size = 36
|
||||
const radius = size / 2
|
||||
const fill = hexWithAlpha(circle.colorHex, 0.2)
|
||||
const isHovered = hoveredSensorId === circle.sensorId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${circle.sensorId}-${Math.round(circle.left)}-${Math.round(circle.top)}`}
|
||||
onClick={() => handleOverlayCircleClick(circle.sensorId)}
|
||||
onMouseEnter={() => setHoveredSensorId(circle.sensorId)}
|
||||
onMouseLeave={() => setHoveredSensorId(null)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: circle.left - radius,
|
||||
top: circle.top - radius,
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '9999px',
|
||||
border: `2px solid ${circle.colorHex}`,
|
||||
backgroundColor: fill,
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
transform: isHovered ? 'scale(1.4)' : 'scale(1)',
|
||||
boxShadow: isHovered
|
||||
? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}`
|
||||
: `0 0 8px ${circle.colorHex}`,
|
||||
zIndex: isHovered ? 50 : 10,
|
||||
}}
|
||||
title={`Датчик: ${circle.sensorId}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{renderOverlay && overlayPos && overlayData
|
||||
? renderOverlay({ anchor: overlayPos, info: overlayData })
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelViewer
|
||||
@@ -59,11 +59,24 @@ export const getSensorIdFromMesh = (m: AbstractMesh | null): string | null => {
|
||||
return null
|
||||
}
|
||||
|
||||
export const collectSensorMeshes = (meshes: AbstractMesh[]): AbstractMesh[] => {
|
||||
export const collectSensorMeshes = (
|
||||
meshes: AbstractMesh[],
|
||||
sensorStatusMap?: Record<string, string> | null
|
||||
): AbstractMesh[] => {
|
||||
const result: AbstractMesh[] = []
|
||||
for (const m of meshes) {
|
||||
const sid = getSensorIdFromMesh(m)
|
||||
if (sid) result.push(m)
|
||||
if (sid) {
|
||||
// Если передана карта статусов - фильтруем только по датчикам из неё
|
||||
if (sensorStatusMap) {
|
||||
if (sid in sensorStatusMap) {
|
||||
result.push(m)
|
||||
}
|
||||
} else {
|
||||
// Если карта не передана - возвращаем все датчики (старое поведение)
|
||||
result.push(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
96
frontend/components/model/sensorHighlight — копия.ts
Normal file
96
frontend/components/model/sensorHighlight — копия.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
AbstractMesh,
|
||||
HighlightLayer,
|
||||
Mesh,
|
||||
InstancedMesh,
|
||||
Color3,
|
||||
} from '@babylonjs/core'
|
||||
import * as statusColors from '../../lib/statusColors'
|
||||
|
||||
export const SENSOR_HIGHLIGHT_COLOR = new Color3(1, 1, 0)
|
||||
|
||||
const CRITICAL_COLOR3 = Color3.FromHexString(statusColors.STATUS_COLOR_CRITICAL)
|
||||
const WARNING_COLOR3 = Color3.FromHexString(statusColors.STATUS_COLOR_WARNING)
|
||||
const NORMAL_COLOR3 = Color3.FromHexString(statusColors.STATUS_COLOR_NORMAL)
|
||||
|
||||
export const statusToColor3 = (status?: string | null): Color3 => {
|
||||
if (!status) return SENSOR_HIGHLIGHT_COLOR
|
||||
const lower = status.toLowerCase()
|
||||
if (lower === 'critical') {
|
||||
return CRITICAL_COLOR3
|
||||
}
|
||||
if (lower === 'warning') {
|
||||
return WARNING_COLOR3
|
||||
}
|
||||
if (lower === 'info' || lower === 'normal' || lower === 'ok') {
|
||||
return NORMAL_COLOR3
|
||||
}
|
||||
if (status === statusColors.STATUS_COLOR_CRITICAL) return CRITICAL_COLOR3
|
||||
if (status === statusColors.STATUS_COLOR_WARNING) return WARNING_COLOR3
|
||||
if (status === statusColors.STATUS_COLOR_NORMAL) return NORMAL_COLOR3
|
||||
return SENSOR_HIGHLIGHT_COLOR
|
||||
}
|
||||
|
||||
export const getSensorIdFromMesh = (m: AbstractMesh | null): string | null => {
|
||||
if (!m) return null
|
||||
try {
|
||||
const meta: any = (m as any)?.metadata
|
||||
const extras: any = meta?.gltf?.extras ?? meta?.extras ?? (m as any)?.extras
|
||||
const sid =
|
||||
extras?.Sensor_ID ??
|
||||
extras?.sensor_id ??
|
||||
extras?.SERIAL_NUMBER ??
|
||||
extras?.serial_number ??
|
||||
extras?.detector_id
|
||||
if (sid != null) return String(sid).trim()
|
||||
const monitoringSensorInstance = extras?.bonsaiPset_ARBM_PSet_MonitoringSensor_Instance
|
||||
if (monitoringSensorInstance && typeof monitoringSensorInstance === 'string') {
|
||||
try {
|
||||
const parsedInstance = JSON.parse(monitoringSensorInstance)
|
||||
const instanceSensorId = parsedInstance?.Sensor_ID
|
||||
if (instanceSensorId != null) return String(instanceSensorId).trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const collectSensorMeshes = (meshes: AbstractMesh[]): AbstractMesh[] => {
|
||||
const result: AbstractMesh[] = []
|
||||
for (const m of meshes) {
|
||||
const sid = getSensorIdFromMesh(m)
|
||||
if (sid) result.push(m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const applyHighlightToMeshes = (
|
||||
layer: HighlightLayer | null,
|
||||
highlightedRef: { current: AbstractMesh[] },
|
||||
meshesToHighlight: AbstractMesh[],
|
||||
getColor?: (mesh: AbstractMesh) => Color3 | null,
|
||||
) => {
|
||||
if (!layer) return
|
||||
for (const m of highlightedRef.current) {
|
||||
m.renderingGroupId = 0
|
||||
}
|
||||
highlightedRef.current = []
|
||||
layer.removeAllMeshes()
|
||||
|
||||
meshesToHighlight.forEach(mesh => {
|
||||
const color = getColor ? getColor(mesh) ?? SENSOR_HIGHLIGHT_COLOR : SENSOR_HIGHLIGHT_COLOR
|
||||
if (mesh instanceof Mesh) {
|
||||
mesh.renderingGroupId = 1
|
||||
highlightedRef.current.push(mesh)
|
||||
layer.addMesh(mesh, color)
|
||||
} else if (mesh instanceof InstancedMesh) {
|
||||
mesh.sourceMesh.renderingGroupId = 1
|
||||
highlightedRef.current.push(mesh.sourceMesh)
|
||||
layer.addMesh(mesh.sourceMesh, color)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -71,14 +71,18 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
||||
|
||||
// Группируем уведомления по дням за последний месяц
|
||||
const now = new Date()
|
||||
const monthAgo = new Date(now.getTime() - DAYS_COUNT * 24 * 60 * 60 * 1000)
|
||||
// Устанавливаем время на начало текущего дня для корректного подсчёта
|
||||
now.setHours(0, 0, 0, 0)
|
||||
|
||||
// Начальная дата: DAYS_COUNT-1 дней назад (чтобы включить текущий день)
|
||||
const startDate = new Date(now.getTime() - (DAYS_COUNT - 1) * 24 * 60 * 60 * 1000)
|
||||
|
||||
// Создаём карту: дата -> { critical: count, warning: count }
|
||||
const dayMap: Record<string, { critical: number; warning: number }> = {}
|
||||
|
||||
// Инициализируем все дни нулями
|
||||
// Инициализируем все дни нулями (включая текущий день)
|
||||
for (let i = 0; i < DAYS_COUNT; i++) {
|
||||
const date = new Date(monthAgo.getTime() + i * 24 * 60 * 60 * 1000)
|
||||
const date = new Date(startDate.getTime() + i * 24 * 60 * 60 * 1000)
|
||||
const dateKey = date.toISOString().split('T')[0]
|
||||
dayMap[dateKey] = { critical: 0, warning: 0 }
|
||||
}
|
||||
@@ -91,7 +95,9 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
||||
}
|
||||
|
||||
const notifDate = new Date(notification.timestamp)
|
||||
if (notifDate >= monthAgo && notifDate <= now) {
|
||||
// Проверяем что уведомление попадает в диапазон от startDate до конца текущего дня
|
||||
const endOfToday = new Date(now.getTime() + 24 * 60 * 60 * 1000)
|
||||
if (notifDate >= startDate && notifDate < endOfToday) {
|
||||
const dateKey = notifDate.toISOString().split('T')[0]
|
||||
if (dayMap[dateKey]) {
|
||||
const notifType = String(notification.type || '').toLowerCase()
|
||||
@@ -121,6 +127,8 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
||||
|
||||
// Определение типа детектора и его отображаемого названия
|
||||
const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase()
|
||||
|
||||
// Извлекаем код из текстового поля type
|
||||
const deriveCodeFromType = (): string => {
|
||||
const t = (detector.type || '').toLowerCase()
|
||||
if (!t) return ''
|
||||
@@ -129,7 +137,15 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
||||
if (t.includes('гидроуров')) return 'GLE'
|
||||
return ''
|
||||
}
|
||||
const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType()
|
||||
|
||||
// Fallback: извлекаем код из префикса серийного номера (GLE-3 -> GLE)
|
||||
const deriveCodeFromSerialNumber = (): string => {
|
||||
const serial = (detector.serial_number || '').toUpperCase()
|
||||
if (!serial) return ''
|
||||
// Ищем префикс до дефиса или цифры
|
||||
const match = serial.match(/^([A-Z]+)[-\d]/)
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
// Карта соответствия кодов типов детекторов их русским названиям
|
||||
const detectorTypeLabelMap: Record<string, string> = {
|
||||
@@ -137,6 +153,15 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
||||
PE: 'Тензометр',
|
||||
GLE: 'Гидроуровень',
|
||||
}
|
||||
|
||||
// Определяем эффективный код типа датчика с fallback
|
||||
let effectiveDetectorTypeCode = rawDetectorTypeCode
|
||||
|
||||
// Если rawDetectorTypeCode не найден в карте - используем fallback
|
||||
if (!detectorTypeLabelMap[effectiveDetectorTypeCode]) {
|
||||
effectiveDetectorTypeCode = deriveCodeFromType() || deriveCodeFromSerialNumber()
|
||||
}
|
||||
|
||||
const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—'
|
||||
|
||||
// Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import useNavigationStore from '@/app/store/navigationStore'
|
||||
|
||||
interface DetectorType {
|
||||
detector_id: number
|
||||
name: string
|
||||
serial_number: string
|
||||
object: string
|
||||
status: string
|
||||
checked: boolean
|
||||
type: string
|
||||
detector_type: string
|
||||
location: string
|
||||
floor: number
|
||||
notifications: Array<{
|
||||
id: number
|
||||
type: string
|
||||
message: string
|
||||
timestamp: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface DetectorMenuProps {
|
||||
detector: DetectorType
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
getStatusText: (status: string) => string
|
||||
compact?: boolean
|
||||
anchor?: { left: number; top: number } | null
|
||||
}
|
||||
|
||||
// Главный компонент меню детектора
|
||||
// Показывает детальную информацию о датчике с возможностью навигации к отчетам и истории
|
||||
const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => {
|
||||
const router = useRouter()
|
||||
const { setSelectedDetector, currentObject } = useNavigationStore()
|
||||
if (!isOpen) return null
|
||||
|
||||
// Определение последней временной метки из уведомлений детектора
|
||||
const latestTimestamp = (() => {
|
||||
const list = detector.notifications ?? []
|
||||
if (!Array.isArray(list) || list.length === 0) return null
|
||||
const dates = list.map(n => new Date(n.timestamp)).filter(d => !isNaN(d.getTime()))
|
||||
if (dates.length === 0) return null
|
||||
dates.sort((a, b) => b.getTime() - a.getTime())
|
||||
return dates[0]
|
||||
})()
|
||||
const formattedTimestamp = latestTimestamp
|
||||
? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
: 'Нет данных'
|
||||
|
||||
// Определение типа детектора и его отображаемого названия
|
||||
const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase()
|
||||
const deriveCodeFromType = (): string => {
|
||||
const t = (detector.type || '').toLowerCase()
|
||||
if (!t) return ''
|
||||
if (t.includes('инклинометр')) return 'GA'
|
||||
if (t.includes('тензометр')) return 'PE'
|
||||
if (t.includes('гидроуров')) return 'GLE'
|
||||
return ''
|
||||
}
|
||||
const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType()
|
||||
|
||||
// Карта соответствия кодов типов детекторов их русским названиям
|
||||
const detectorTypeLabelMap: Record<string, string> = {
|
||||
GA: 'Инклинометр',
|
||||
PE: 'Тензометр',
|
||||
GLE: 'Гидроуровень',
|
||||
}
|
||||
const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—'
|
||||
|
||||
// Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором
|
||||
const handleReportsClick = () => {
|
||||
const currentUrl = new URL(window.location.href)
|
||||
const objectId = currentUrl.searchParams.get('objectId') || currentObject.id
|
||||
const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title
|
||||
|
||||
const detectorData = {
|
||||
...detector,
|
||||
notifications: detector.notifications || []
|
||||
}
|
||||
setSelectedDetector(detectorData)
|
||||
|
||||
let reportsUrl = '/reports'
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (objectId) params.set('objectId', objectId)
|
||||
if (objectTitle) params.set('objectTitle', objectTitle)
|
||||
|
||||
if (params.toString()) {
|
||||
reportsUrl += `?${params.toString()}`
|
||||
}
|
||||
|
||||
router.push(reportsUrl)
|
||||
}
|
||||
|
||||
// Обработчик клика по кнопке "История" - навигация на страницу истории тревог с выбранным детектором
|
||||
const handleHistoryClick = () => {
|
||||
const currentUrl = new URL(window.location.href)
|
||||
const objectId = currentUrl.searchParams.get('objectId') || currentObject.id
|
||||
const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title
|
||||
|
||||
const detectorData = {
|
||||
...detector,
|
||||
notifications: detector.notifications || []
|
||||
}
|
||||
setSelectedDetector(detectorData)
|
||||
|
||||
let alertsUrl = '/alerts'
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (objectId) params.set('objectId', objectId)
|
||||
if (objectTitle) params.set('objectTitle', objectTitle)
|
||||
|
||||
if (params.toString()) {
|
||||
alertsUrl += `?${params.toString()}`
|
||||
}
|
||||
|
||||
router.push(alertsUrl)
|
||||
}
|
||||
|
||||
// Компонент секции деталей детектора
|
||||
// Отображает информацию о датчике в компактном или полном формате
|
||||
const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => (
|
||||
<div className={compact ? 'mt-2 space-y-1' : 'space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden'}>
|
||||
{compact ? (
|
||||
// Компактный режим: 4 строки по 2 колонки с основной информацией
|
||||
<>
|
||||
{/* Строка 1: Маркировка и тип детектора */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Маркировка по проекту</div>
|
||||
<div className="text-white text-xs truncate">{detector.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Тип детектора</div>
|
||||
<div className="text-white text-xs truncate">{displayDetectorTypeLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Строка 2: Местоположение и статус */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
|
||||
<div className="text-white text-xs truncate">{detector.location}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Статус</div>
|
||||
<div className="text-white text-xs truncate">{getStatusText(detector.status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Строка 3: Временная метка и этаж */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
|
||||
<div className="text-white text-xs truncate">{formattedTimestamp}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Этаж</div>
|
||||
<div className="text-white text-xs truncate">{detector.floor}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Строка 4: Серийный номер */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Серийный номер</div>
|
||||
<div className="text-white text-xs truncate">{detector.serial_number}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Полный режим: 3 строки по 2 колонки с рамками между элементами
|
||||
<>
|
||||
{/* Строка 1: Маркировка по проекту и тип детектора */}
|
||||
<div className="flex">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
|
||||
<div className="text-white text-sm">{detector.name}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Тип детектора</div>
|
||||
<div className="text-white text-sm">{displayDetectorTypeLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Строка 2: Местоположение и статус */}
|
||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
|
||||
<div className="text-white text-sm">{detector.location}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Статус</div>
|
||||
<div className="text-white text-sm">{getStatusText(detector.status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Строка 3: Временная метка и серийный номер */}
|
||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Временная метка</div>
|
||||
<div className="text-white text-sm">{formattedTimestamp}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Серийный номер</div>
|
||||
<div className="text-white text-sm">{detector.serial_number}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Компактный режим с якорной позицией (всплывающее окно)
|
||||
// Используется для отображения информации при наведении на детектор в списке
|
||||
if (compact && anchor) {
|
||||
return (
|
||||
<div className="absolute z-40" style={{ left: anchor.left, top: anchor.top }}>
|
||||
<div className="rounded-[10px] bg-black/80 text-white text-xs px-3 py-2 shadow-xl min-w-[300px] max-w-[400px]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold truncate">{detector.name}</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-300 hover:text-white transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Отчет
|
||||
</button>
|
||||
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
История
|
||||
</button>
|
||||
</div>
|
||||
<DetailsSection compact={true} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Полный режим боковой панели (основной режим)
|
||||
// Отображается как правая панель с полной информацией о детекторе
|
||||
return (
|
||||
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 з-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||
<div className="h-full overflow-auto p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{/* Заголовок с названием детектора */}
|
||||
<h3 className="text-white text-lg font-medium">
|
||||
{detector.name}
|
||||
</h3>
|
||||
{/* Кнопки действий: Отчет и История */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Отчет
|
||||
</button>
|
||||
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
История
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Секция с детальной информацией о детекторе */}
|
||||
<DetailsSection />
|
||||
|
||||
{/* Кнопка закрытия панели */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6л12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetectorMenu
|
||||
@@ -1,329 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import useNavigationStore from '@/app/store/navigationStore'
|
||||
import AreaChart from '../dashboard/AreaChart'
|
||||
|
||||
interface DetectorType {
|
||||
detector_id: number
|
||||
name: string
|
||||
serial_number: string
|
||||
object: string
|
||||
status: string
|
||||
checked: boolean
|
||||
type: string
|
||||
detector_type: string
|
||||
location: string
|
||||
floor: number
|
||||
notifications: Array<{
|
||||
id: number
|
||||
type: string
|
||||
message: string
|
||||
timestamp: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface DetectorMenuProps {
|
||||
detector: DetectorType
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
getStatusText: (status: string) => string
|
||||
compact?: boolean
|
||||
anchor?: { left: number; top: number } | null
|
||||
}
|
||||
|
||||
// Главный компонент меню детектора
|
||||
// Показывает детальную информацию о датчике с возможностью навигации к отчетам и истории
|
||||
const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => {
|
||||
const router = useRouter()
|
||||
const { setSelectedDetector, currentObject } = useNavigationStore()
|
||||
if (!isOpen) return null
|
||||
|
||||
// Определение последней временной метки из уведомлений детектора
|
||||
const latestTimestamp = (() => {
|
||||
const list = detector.notifications ?? []
|
||||
if (!Array.isArray(list) || list.length === 0) return null
|
||||
const dates = list.map(n => new Date(n.timestamp)).filter(d => !isNaN(d.getTime()))
|
||||
if (dates.length === 0) return null
|
||||
dates.sort((a, b) => b.getTime() - a.getTime())
|
||||
return dates[0]
|
||||
})()
|
||||
const formattedTimestamp = latestTimestamp
|
||||
? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
: 'Нет данных'
|
||||
|
||||
// Данные для графика за последние 3 дня (мок данные)
|
||||
const chartData: { timestamp: string; value: number }[] = [
|
||||
{ timestamp: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), value: 75 },
|
||||
{ timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), value: 82 },
|
||||
{ timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), value: 78 },
|
||||
{ timestamp: new Date().toISOString(), value: 85 },
|
||||
]
|
||||
|
||||
// Определение типа детектора и его отображаемого названия
|
||||
const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase()
|
||||
const deriveCodeFromType = (): string => {
|
||||
const t = (detector.type || '').toLowerCase()
|
||||
if (!t) return ''
|
||||
if (t.includes('инклинометр')) return 'GA'
|
||||
if (t.includes('тензометр')) return 'PE'
|
||||
if (t.includes('гидроуров')) return 'GLE'
|
||||
return ''
|
||||
}
|
||||
const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType()
|
||||
|
||||
// Карта соответствия кодов типов детекторов их русским названиям
|
||||
const detectorTypeLabelMap: Record<string, string> = {
|
||||
GA: 'Инклинометр',
|
||||
PE: 'Тензометр',
|
||||
GLE: 'Гидроуровень',
|
||||
}
|
||||
const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—'
|
||||
|
||||
// Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором
|
||||
const handleReportsClick = () => {
|
||||
const currentUrl = new URL(window.location.href)
|
||||
const objectId = currentUrl.searchParams.get('objectId') || currentObject.id
|
||||
const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title
|
||||
|
||||
const detectorData = {
|
||||
...detector,
|
||||
notifications: detector.notifications || []
|
||||
}
|
||||
setSelectedDetector(detectorData)
|
||||
|
||||
let reportsUrl = '/reports'
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (objectId) params.set('objectId', objectId)
|
||||
if (objectTitle) params.set('objectTitle', objectTitle)
|
||||
|
||||
if (params.toString()) {
|
||||
reportsUrl += `?${params.toString()}`
|
||||
}
|
||||
|
||||
router.push(reportsUrl)
|
||||
}
|
||||
|
||||
// Обработчик клика по кнопке "История" - навигация на страницу истории тревог с выбранным детектором
|
||||
const handleHistoryClick = () => {
|
||||
const currentUrl = new URL(window.location.href)
|
||||
const objectId = currentUrl.searchParams.get('objectId') || currentObject.id
|
||||
const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title
|
||||
|
||||
const detectorData = {
|
||||
...detector,
|
||||
notifications: detector.notifications || []
|
||||
}
|
||||
setSelectedDetector(detectorData)
|
||||
|
||||
let alertsUrl = '/alerts'
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (objectId) params.set('objectId', objectId)
|
||||
if (objectTitle) params.set('objectTitle', objectTitle)
|
||||
|
||||
if (params.toString()) {
|
||||
alertsUrl += `?${params.toString()}`
|
||||
}
|
||||
|
||||
router.push(alertsUrl)
|
||||
}
|
||||
|
||||
// Компонент секции деталей детектора
|
||||
// Отображает информацию о датчике в компактном или полном формате
|
||||
const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => (
|
||||
<div className={compact ? 'mt-2 space-y-1' : 'space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden'}>
|
||||
{compact ? (
|
||||
// Компактный режим: 4 строки по 2 колонки с основной информацией
|
||||
<>
|
||||
{/* Строка 1: Маркировка и тип детектора */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Маркировка по проекту</div>
|
||||
<div className="text-white text-xs truncate">{detector.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Тип детектора</div>
|
||||
<div className="text-white text-xs truncate">{displayDetectorTypeLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Строка 2: Местоположение и статус */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
|
||||
<div className="text-white text-xs truncate">{detector.location}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Статус</div>
|
||||
<div className="text-white text-xs truncate">{getStatusText(detector.status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Строка 3: Временная метка и этаж */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
|
||||
<div className="text-white text-xs truncate">{formattedTimestamp}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Этаж</div>
|
||||
<div className="text-white text-xs truncate">{detector.floor}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Строка 4: Серийный номер */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Серийный номер</div>
|
||||
<div className="text-white text-xs truncate">{detector.serial_number}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Полный режим: 3 строки по 2 колонки с рамками между элементами
|
||||
<>
|
||||
{/* Строка 1: Маркировка по проекту и тип детектора */}
|
||||
<div className="flex">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
|
||||
<div className="text-white text-sm">{detector.name}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Тип детектора</div>
|
||||
<div className="text-white text-sm">{displayDetectorTypeLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Строка 2: Местоположение и статус */}
|
||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
|
||||
<div className="text-white text-sm">{detector.location}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Статус</div>
|
||||
<div className="text-white text-sm">{getStatusText(detector.status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Строка 3: Временная метка и серийный номер */}
|
||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Временная метка</div>
|
||||
<div className="text-white text-sm">{formattedTimestamp}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Серийный номер</div>
|
||||
<div className="text-white text-sm">{detector.serial_number}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Компактный режим с якорной позицией (всплывающее окно)
|
||||
// Используется для отображения информации при наведении на детектор в списке
|
||||
if (compact && anchor) {
|
||||
// Проверяем границы экрана и корректируем позицию
|
||||
const tooltipHeight = 450 // Примерная высота толтипа с графиком
|
||||
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800
|
||||
const bottomOverflow = anchor.top + tooltipHeight - viewportHeight
|
||||
|
||||
// Если толтип выходит за нижнюю границу, сдвигаем вверх
|
||||
const adjustedTop = bottomOverflow > 0 ? anchor.top - bottomOverflow - 20 : anchor.top
|
||||
|
||||
return (
|
||||
<div className="absolute z-40" style={{ left: anchor.left, top: adjustedTop }}>
|
||||
<div className="rounded-[10px] bg-black/80 text-white text-xs px-3 py-2 shadow-xl min-w-[300px] max-w-[400px]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold truncate">{detector.name}</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-300 hover:text-white transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Отчет
|
||||
</button>
|
||||
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
История
|
||||
</button>
|
||||
</div>
|
||||
<DetailsSection compact={true} />
|
||||
|
||||
{/* График за последние 3 дня */}
|
||||
<div className="mt-3">
|
||||
<div className="text-[rgb(113,113,122)] text-[11px] mb-2">График за 3 дня</div>
|
||||
<div className="min-h-[100px]">
|
||||
<AreaChart data={chartData} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Полный режим боковой панели (основной режим)
|
||||
// Отображается как правая панель с полной информацией о детекторе
|
||||
return (
|
||||
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 з-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||
<div className="h-full overflow-auto p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{/* Заголовок с названием детектора */}
|
||||
<h3 className="text-white text-lg font-medium">
|
||||
{detector.name}
|
||||
</h3>
|
||||
{/* Кнопки действий: Отчет и История */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Отчет
|
||||
</button>
|
||||
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
История
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Секция с детальной информацией о детекторе */}
|
||||
<DetailsSection />
|
||||
|
||||
{/* График за последние 3 дня */}
|
||||
<div className="mt-6">
|
||||
<h4 className="text-white text-base font-medium mb-4">График за последние 3 дня</h4>
|
||||
<div className="min-h-[200px] bg-[rgb(22,24,36)] rounded-lg p-4">
|
||||
<AreaChart data={chartData} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопка закрытия панели */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6л12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetectorMenu
|
||||
@@ -1,329 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import useNavigationStore from '@/app/store/navigationStore'
|
||||
import AreaChart from '../dashboard/AreaChart'
|
||||
|
||||
interface DetectorType {
|
||||
detector_id: number
|
||||
name: string
|
||||
serial_number: string
|
||||
object: string
|
||||
status: string
|
||||
checked: boolean
|
||||
type: string
|
||||
detector_type: string
|
||||
location: string
|
||||
floor: number
|
||||
notifications: Array<{
|
||||
id: number
|
||||
type: string
|
||||
message: string
|
||||
timestamp: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface DetectorMenuProps {
|
||||
detector: DetectorType
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
getStatusText: (status: string) => string
|
||||
compact?: boolean
|
||||
anchor?: { left: number; top: number } | null
|
||||
}
|
||||
|
||||
// Главный компонент меню детектора
|
||||
// Показывает детальную информацию о датчике с возможностью навигации к отчетам и истории
|
||||
const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => {
|
||||
const router = useRouter()
|
||||
const { setSelectedDetector, currentObject } = useNavigationStore()
|
||||
if (!isOpen) return null
|
||||
|
||||
// Определение последней временной метки из уведомлений детектора
|
||||
const latestTimestamp = (() => {
|
||||
const list = detector.notifications ?? []
|
||||
if (!Array.isArray(list) || list.length === 0) return null
|
||||
const dates = list.map(n => new Date(n.timestamp)).filter(d => !isNaN(d.getTime()))
|
||||
if (dates.length === 0) return null
|
||||
dates.sort((a, b) => b.getTime() - a.getTime())
|
||||
return dates[0]
|
||||
})()
|
||||
const formattedTimestamp = latestTimestamp
|
||||
? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
: 'Нет данных'
|
||||
|
||||
// Данные для графика за последние 3 дня (мок данные)
|
||||
const chartData = [
|
||||
{ timestamp: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), critical: 75, warning: 0 },
|
||||
{ timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), critical: 82, warning: 0 },
|
||||
{ timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), critical: 78, warning: 0 },
|
||||
{ timestamp: new Date().toISOString(), critical: 85, warning: 0 },
|
||||
]
|
||||
|
||||
// Определение типа детектора и его отображаемого названия
|
||||
const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase()
|
||||
const deriveCodeFromType = (): string => {
|
||||
const t = (detector.type || '').toLowerCase()
|
||||
if (!t) return ''
|
||||
if (t.includes('инклинометр')) return 'GA'
|
||||
if (t.includes('тензометр')) return 'PE'
|
||||
if (t.includes('гидроуров')) return 'GLE'
|
||||
return ''
|
||||
}
|
||||
const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType()
|
||||
|
||||
// Карта соответствия кодов типов детекторов их русским названиям
|
||||
const detectorTypeLabelMap: Record<string, string> = {
|
||||
GA: 'Инклинометр',
|
||||
PE: 'Тензометр',
|
||||
GLE: 'Гидроуровень',
|
||||
}
|
||||
const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—'
|
||||
|
||||
// Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором
|
||||
const handleReportsClick = () => {
|
||||
const currentUrl = new URL(window.location.href)
|
||||
const objectId = currentUrl.searchParams.get('objectId') || currentObject.id
|
||||
const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title
|
||||
|
||||
const detectorData = {
|
||||
...detector,
|
||||
notifications: detector.notifications || []
|
||||
}
|
||||
setSelectedDetector(detectorData)
|
||||
|
||||
let reportsUrl = '/reports'
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (objectId) params.set('objectId', objectId)
|
||||
if (objectTitle) params.set('objectTitle', objectTitle)
|
||||
|
||||
if (params.toString()) {
|
||||
reportsUrl += `?${params.toString()}`
|
||||
}
|
||||
|
||||
router.push(reportsUrl)
|
||||
}
|
||||
|
||||
// Обработчик клика по кнопке "История" - навигация на страницу истории тревог с выбранным детектором
|
||||
const handleHistoryClick = () => {
|
||||
const currentUrl = new URL(window.location.href)
|
||||
const objectId = currentUrl.searchParams.get('objectId') || currentObject.id
|
||||
const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title
|
||||
|
||||
const detectorData = {
|
||||
...detector,
|
||||
notifications: detector.notifications || []
|
||||
}
|
||||
setSelectedDetector(detectorData)
|
||||
|
||||
let alertsUrl = '/alerts'
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (objectId) params.set('objectId', objectId)
|
||||
if (objectTitle) params.set('objectTitle', objectTitle)
|
||||
|
||||
if (params.toString()) {
|
||||
alertsUrl += `?${params.toString()}`
|
||||
}
|
||||
|
||||
router.push(alertsUrl)
|
||||
}
|
||||
|
||||
// Компонент секции деталей детектора
|
||||
// Отображает информацию о датчике в компактном или полном формате
|
||||
const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => (
|
||||
<div className={compact ? 'mt-2 space-y-1' : 'space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden'}>
|
||||
{compact ? (
|
||||
// Компактный режим: 4 строки по 2 колонки с основной информацией
|
||||
<>
|
||||
{/* Строка 1: Маркировка и тип детектора */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Маркировка по проекту</div>
|
||||
<div className="text-white text-xs truncate">{detector.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Тип детектора</div>
|
||||
<div className="text-white text-xs truncate">{displayDetectorTypeLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Строка 2: Местоположение и статус */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
|
||||
<div className="text-white text-xs truncate">{detector.location}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Статус</div>
|
||||
<div className="text-white text-xs truncate">{getStatusText(detector.status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Строка 3: Временная метка и этаж */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
|
||||
<div className="text-white text-xs truncate">{formattedTimestamp}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Этаж</div>
|
||||
<div className="text-white text-xs truncate">{detector.floor}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Строка 4: Серийный номер */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[rgb(113,113,122)] text-[11px]">Серийный номер</div>
|
||||
<div className="text-white text-xs truncate">{detector.serial_number}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Полный режим: 3 строки по 2 колонки с рамками между элементами
|
||||
<>
|
||||
{/* Строка 1: Маркировка по проекту и тип детектора */}
|
||||
<div className="flex">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
|
||||
<div className="text-white text-sm">{detector.name}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Тип детектора</div>
|
||||
<div className="text-white text-sm">{displayDetectorTypeLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Строка 2: Местоположение и статус */}
|
||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
|
||||
<div className="text-white text-sm">{detector.location}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Статус</div>
|
||||
<div className="text-white text-sm">{getStatusText(detector.status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Строка 3: Временная метка и серийный номер */}
|
||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Временная метка</div>
|
||||
<div className="text-white text-sm">{formattedTimestamp}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Серийный номер</div>
|
||||
<div className="text-white text-sm">{detector.serial_number}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Компактный режим с якорной позицией (всплывающее окно)
|
||||
// Используется для отображения информации при наведении на детектор в списке
|
||||
if (compact && anchor) {
|
||||
// Проверяем границы экрана и корректируем позицию
|
||||
const tooltipHeight = 450 // Примерная высота толтипа с графиком
|
||||
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800
|
||||
const bottomOverflow = anchor.top + tooltipHeight - viewportHeight
|
||||
|
||||
// Если толтип выходит за нижнюю границу, сдвигаем вверх
|
||||
const adjustedTop = bottomOverflow > 0 ? anchor.top - bottomOverflow - 20 : anchor.top
|
||||
|
||||
return (
|
||||
<div className="absolute z-40" style={{ left: anchor.left, top: adjustedTop }}>
|
||||
<div className="rounded-[10px] bg-black/80 text-white text-xs px-3 py-2 shadow-xl min-w-[300px] max-w-[400px]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold truncate">{detector.name}</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-300 hover:text-white transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Отчет
|
||||
</button>
|
||||
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
История
|
||||
</button>
|
||||
</div>
|
||||
<DetailsSection compact={true} />
|
||||
|
||||
{/* График за последние 3 дня */}
|
||||
<div className="mt-3">
|
||||
<div className="text-[rgb(113,113,122)] text-[11px] mb-2">График за 3 дня</div>
|
||||
<div className="min-h-[100px]">
|
||||
<AreaChart data={chartData} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Полный режим боковой панели (основной режим)
|
||||
// Отображается как правая панель с полной информацией о детекторе
|
||||
return (
|
||||
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 з-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||
<div className="h-full overflow-auto p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{/* Заголовок с названием детектора */}
|
||||
<h3 className="text-white text-lg font-medium">
|
||||
{detector.name}
|
||||
</h3>
|
||||
{/* Кнопки действий: Отчет и История */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Отчет
|
||||
</button>
|
||||
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
История
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Секция с детальной информацией о детекторе */}
|
||||
<DetailsSection />
|
||||
|
||||
{/* График за последние 3 дня */}
|
||||
<div className="mt-6">
|
||||
<h4 className="text-white text-base font-medium mb-4">График за последние 3 дня</h4>
|
||||
<div className="min-h-[200px] bg-[rgb(22,24,36)] rounded-lg p-4">
|
||||
<AreaChart data={chartData} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопка закрытия панели */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6л12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetectorMenu
|
||||
Reference in New Issue
Block a user