изменение логики работы поиска датчиков из дашборд и истории тревог по конкретному этажу

This commit is contained in:
2026-02-05 22:21:25 +03:00
parent 6caf1c9dbb
commit bad3b63911
13 changed files with 2861 additions and 793 deletions

View File

@@ -0,0 +1,658 @@
'use client'
import React, { useEffect, useCallback, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Image from 'next/image'
import Sidebar from '../../../components/ui/Sidebar'
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
import useNavigationStore from '../../store/navigationStore'
import Monitoring from '../../../components/navigation/Monitoring'
import FloorNavigation from '../../../components/navigation/FloorNavigation'
import DetectorMenu from '../../../components/navigation/DetectorMenu'
import ListOfDetectors from '../../../components/navigation/ListOfDetectors'
import Sensors from '../../../components/navigation/Sensors'
import AlertMenu from '../../../components/navigation/AlertMenu'
import Notifications from '../../../components/notifications/Notifications'
import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo'
import dynamic from 'next/dynamic'
import type { ModelViewerProps } from '../../../components/model/ModelViewer'
import * as statusColors from '../../../lib/statusColors'
const ModelViewer = dynamic<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
// Логируем GLE-1
if (d.serial_number === 'GLE-1') {
console.log('[NavigationPage] GLE-1 status from API:', d.status)
console.log('[NavigationPage] GLE-1 notifications:', d.notifications)
}
}
})
console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors')
console.log('[NavigationPage] Sample sensor IDs in map:', Object.keys(map).slice(0, 5))
return map
}, [detectorsData])
useEffect(() => {
if (selectedDetector === null && selectedAlert === null) {
setFocusedSensorId(null);
}
}, [selectedDetector, selectedAlert]);
// Управление выделением всех сенсоров при открытии/закрытии меню Sensors
// ИСПРАВЛЕНО: Подсветка датчиков остается включенной всегда, независимо от состояния панели Sensors
useEffect(() => {
console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady)
if (isModelReady) {
// Всегда включаем подсветку всех сенсоров когда модель готова
console.log('[NavigationPage] Setting highlightAllSensors to TRUE (always enabled)')
setHighlightAllSensors(true)
// Сбрасываем фокус только если панель Sensors закрыта
if (!showSensors) {
setFocusedSensorId(null)
}
}
}, [showSensors, isModelReady])
// Дополнительный эффект для задержки выделения сенсоров при открытии меню
// ИСПРАВЛЕНО: Задержка применяется только при открытии панели Sensors
useEffect(() => {
if (showSensors && isModelReady) {
const timer = setTimeout(() => {
console.log('[NavigationPage] Delayed highlightAllSensors to TRUE')
setHighlightAllSensors(true)
}, 500) // Задержка 500мс для полной инициализации модели
return () => clearTimeout(timer)
}
}, [showSensors, isModelReady])
const urlObjectId = searchParams.get('objectId')
const urlObjectTitle = searchParams.get('objectTitle')
const urlModelPath = searchParams.get('modelPath')
const urlFocusSensorId = searchParams.get('focusSensorId')
const objectId = currentObject.id || urlObjectId
const objectTitle = currentObject.title || urlObjectTitle
const [selectedModelPath, setSelectedModelPath] = useState<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)
// Если есть modelPath и objectId - фильтруем по зоне
let zoneId: string | null = null
if (selectedModelPath && objectId) {
try {
// Получаем зоны для объекта
const zonesRes = await fetch(`/api/get-zones?objectId=${objectId}`, { cache: 'no-store' })
if (zonesRes.ok) {
const zonesResponse = await zonesRes.json()
// API возвращает { success: true, data: [...] }
const zonesData = zonesResponse?.data || zonesResponse
console.log('[NavigationPage] Loaded zones:', { count: Array.isArray(zonesData) ? zonesData.length : 0, zonesData })
// Ищем зону по model_path
if (Array.isArray(zonesData)) {
const zone = zonesData.find((z: any) => z.model_path === selectedModelPath)
if (zone) {
zoneId = zone.id
console.log('[NavigationPage] Found zone for model_path:', { modelPath: selectedModelPath, zoneId })
} else {
console.log('[NavigationPage] No zone found for model_path:', selectedModelPath)
}
}
}
} catch (e) {
console.warn('[NavigationPage] Failed to load zones for filtering:', e)
}
}
// Загружаем датчики (с фильтром по зоне если найдена)
const detectorsUrl = zoneId
? `/api/get-detectors?zone_id=${zoneId}`
: '/api/get-detectors'
console.log('[NavigationPage] Loading detectors from:', detectorsUrl)
const res = await fetch(detectorsUrl, { cache: 'no-store' })
const text = await res.text()
let payload: any
try { payload = JSON.parse(text) } catch { payload = text }
console.log('[NavigationPage] GET /api/get-detectors', { status: res.status, payload })
if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов'))
const data = payload?.data ?? payload
const detectors = (data?.detectors ?? {}) as Record<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()
}, [selectedModelPath, objectId])
const handleBackClick = () => {
router.push('/dashboard')
}
const handleDetectorMenuClick = (detector: DetectorType) => {
// Для тестов. Выбор детектора.
console.log('[NavigationPage] Selected detector click:', {
detector_id: detector.detector_id,
name: detector.name,
serial_number: detector.serial_number,
})
// Проверяем, что детектор имеет необходимые данные
if (!detector || !detector.detector_id || !detector.serial_number) {
console.warn('[NavigationPage] Invalid detector data, skipping menu display:', detector)
return
}
if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) {
closeDetectorMenu()
} else {
setSelectedDetector(detector)
setShowDetectorMenu(true)
setFocusedSensorId(detector.serial_number)
setShowAlertMenu(false)
setSelectedAlert(null)
// При открытии меню детектора - сбрасываем множественное выделение
setHighlightAllSensors(false)
}
}
const closeDetectorMenu = () => {
setShowDetectorMenu(false)
setSelectedDetector(null)
setFocusedSensorId(null)
setSelectedAlert(null)
// При закрытии меню детектора - выделяем все сенсоры снова
setHighlightAllSensors(true)
}
const handleNotificationClick = (notification: NotificationType) => {
if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) {
setShowNotificationDetectorInfo(false)
setSelectedNotification(null)
} else {
setSelectedNotification(notification)
setShowNotificationDetectorInfo(true)
}
}
const closeNotificationDetectorInfo = () => {
setShowNotificationDetectorInfo(false)
setSelectedNotification(null)
}
const closeAlertMenu = () => {
setShowAlertMenu(false)
setSelectedAlert(null)
setFocusedSensorId(null)
setSelectedDetector(null)
// При закрытии меню алерта - выделяем все сенсоры снова
setHighlightAllSensors(true)
}
const handleAlertClick = (alert: AlertType) => {
console.log('[NavigationPage] Alert clicked, focusing on detector in 3D scene:', alert)
const detector = Object.values(detectorsData.detectors).find(
d => d.detector_id === alert.detector_id
)
if (detector) {
if (selectedAlert?.id === alert.id && showAlertMenu) {
closeAlertMenu()
} else {
setSelectedAlert(alert)
setShowAlertMenu(true)
setFocusedSensorId(detector.serial_number)
setShowDetectorMenu(false)
setSelectedDetector(null)
// При открытии меню алерта - сбрасываем множественное выделение
setHighlightAllSensors(false)
console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name)
}
} else {
console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id)
}
}
const handleSensorSelection = (serialNumber: string | null) => {
if (serialNumber === null) {
setFocusedSensorId(null);
closeDetectorMenu();
closeAlertMenu();
// If we're in Sensors menu and no sensor is selected, highlight all sensors
if (showSensors) {
setHighlightAllSensors(true);
}
return;
}
if (focusedSensorId === serialNumber) {
setFocusedSensorId(null);
closeDetectorMenu();
closeAlertMenu();
// If we're in Sensors menu and deselected the current sensor, highlight all sensors
if (showSensors) {
setHighlightAllSensors(true);
}
return;
}
// При выборе конкретного сенсора - сбрасываем множественное выделение
setHighlightAllSensors(false)
const detector = Object.values(detectorsData?.detectors || {}).find(
(d) => d.serial_number === serialNumber
);
if (detector) {
// Всегда показываем меню детектора для всех датчиков
handleDetectorMenuClick(detector);
} else {
setFocusedSensorId(null);
closeDetectorMenu();
closeAlertMenu();
// If we're in Sensors menu and no valid detector found, highlight all sensors
if (showSensors) {
setHighlightAllSensors(true);
}
}
};
// Обработка focusSensorId из URL (при переходе из таблиц событий)
useEffect(() => {
if (urlFocusSensorId && isModelReady && detectorsData) {
console.log('[NavigationPage] Setting focusSensorId from URL:', urlFocusSensorId)
setFocusedSensorId(urlFocusSensorId)
setHighlightAllSensors(false)
// Автоматически открываем тултип датчика
setTimeout(() => {
handleSensorSelection(urlFocusSensorId)
}, 500) // Задержка для полной инициализации
// Очищаем URL от параметра после применения
const newUrl = new URL(window.location.href)
newUrl.searchParams.delete('focusSensorId')
window.history.replaceState({}, '', newUrl.toString())
}
}, [urlFocusSensorId, isModelReady, detectorsData])
const getStatusText = (status: string) => {
const s = (status || '').toLowerCase()
switch (s) {
case statusColors.STATUS_COLOR_CRITICAL:
case 'critical':
return 'Критический'
case statusColors.STATUS_COLOR_WARNING:
case 'warning':
return 'Предупреждение'
case statusColors.STATUS_COLOR_NORMAL:
case 'normal':
return 'Норма'
default:
return 'Неизвестно'
}
}
return (
<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

View File

@@ -0,0 +1,687 @@
'use client'
import React, { useEffect, useCallback, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Image from 'next/image'
import Sidebar from '../../../components/ui/Sidebar'
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
import useNavigationStore from '../../store/navigationStore'
import Monitoring from '../../../components/navigation/Monitoring'
import FloorNavigation from '../../../components/navigation/FloorNavigation'
import DetectorMenu from '../../../components/navigation/DetectorMenu'
import ListOfDetectors from '../../../components/navigation/ListOfDetectors'
import Sensors from '../../../components/navigation/Sensors'
import AlertMenu from '../../../components/navigation/AlertMenu'
import Notifications from '../../../components/notifications/Notifications'
import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo'
import dynamic from 'next/dynamic'
import type { ModelViewerProps } from '../../../components/model/ModelViewer'
import * as statusColors from '../../../lib/statusColors'
const ModelViewer = dynamic<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
// Логируем GLE-1
if (d.serial_number === 'GLE-1') {
console.log('[NavigationPage] GLE-1 status from API:', d.status)
console.log('[NavigationPage] GLE-1 notifications:', d.notifications)
}
}
})
console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors')
console.log('[NavigationPage] Sample sensor IDs in map:', Object.keys(map).slice(0, 5))
return map
}, [detectorsData])
useEffect(() => {
if (selectedDetector === null && selectedAlert === null) {
setFocusedSensorId(null);
}
}, [selectedDetector, selectedAlert]);
// Управление выделением всех сенсоров при открытии/закрытии меню Sensors
// ИСПРАВЛЕНО: Подсветка датчиков остается включенной всегда, независимо от состояния панели Sensors
useEffect(() => {
console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady)
if (isModelReady) {
// Всегда включаем подсветку всех сенсоров когда модель готова
console.log('[NavigationPage] Setting highlightAllSensors to TRUE (always enabled)')
setHighlightAllSensors(true)
// Сбрасываем фокус только если панель Sensors закрыта
if (!showSensors) {
setFocusedSensorId(null)
}
}
}, [showSensors, isModelReady])
// Дополнительный эффект для задержки выделения сенсоров при открытии меню
// ИСПРАВЛЕНО: Задержка применяется только при открытии панели Sensors
useEffect(() => {
if (showSensors && isModelReady) {
const timer = setTimeout(() => {
console.log('[NavigationPage] Delayed highlightAllSensors to TRUE')
setHighlightAllSensors(true)
}, 500) // Задержка 500мс для полной инициализации модели
return () => clearTimeout(timer)
}
}, [showSensors, isModelReady])
const urlObjectId = searchParams.get('objectId')
const urlObjectTitle = searchParams.get('objectTitle')
const urlModelPath = searchParams.get('modelPath')
const urlFocusSensorId = searchParams.get('focusSensorId')
const objectId = currentObject.id || urlObjectId
const objectTitle = currentObject.title || urlObjectTitle
const [selectedModelPath, setSelectedModelPath] = useState<string>('')
const handleModelLoaded = useCallback(() => {
setIsModelReady(true)
setModelError(null)
}, [])
const handleModelError = useCallback((error: string) => {
console.error('[NavigationPage] Model loading error:', error)
setModelError(error)
setIsModelReady(false)
}, [])
useEffect(() => {
if (selectedModelPath) {
setIsModelReady(false);
setModelError(null);
}
}, [selectedModelPath]);
useEffect(() => {
if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) {
setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined)
}
}, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject])
// Восстановление выбранной модели из URL при загрузке страницы (только если переход с focusSensorId)
useEffect(() => {
// Восстанавливаем modelPath только если есть focusSensorId (переход к конкретному датчику)
if (urlModelPath && urlFocusSensorId && !selectedModelPath) {
setSelectedModelPath(urlModelPath);
}
}, [urlModelPath, urlFocusSensorId, selectedModelPath])
// Автоматическая загрузка модели с order=0 при пустом selectedModelPath
useEffect(() => {
const loadDefaultModel = async () => {
// Если модель уже выбрана или нет objectId - пропускаем
if (selectedModelPath || !objectId) return;
try {
console.log('[NavigationPage] Auto-loading model with order=0 for object:', objectId);
const response = await fetch(`/api/get-zones?object_id=${objectId}`);
const data = await response.json();
if (data.success && Array.isArray(data.data)) {
// Сортируем по order и берём первую
const sorted = data.data.slice().sort((a: any, b: any) => {
const oa = typeof a.order === 'number' ? a.order : 0;
const ob = typeof b.order === 'number' ? b.order : 0;
return oa - ob;
});
if (sorted.length > 0 && sorted[0].model_path) {
console.log('[NavigationPage] Auto-selected model with order=0:', sorted[0].model_path);
setSelectedModelPath(sorted[0].model_path);
}
}
} catch (error) {
console.error('[NavigationPage] Failed to load default model:', error);
}
};
loadDefaultModel();
}, [selectedModelPath, objectId])
useEffect(() => {
const loadDetectors = async () => {
try {
setDetectorsError(null)
// Если есть modelPath и objectId - фильтруем по зоне
let zoneId: string | null = null
if (selectedModelPath && objectId) {
try {
// Получаем зоны для объекта
const zonesRes = await fetch(`/api/get-zones?objectId=${objectId}`, { cache: 'no-store' })
if (zonesRes.ok) {
const zonesResponse = await zonesRes.json()
// API возвращает { success: true, data: [...] }
const zonesData = zonesResponse?.data || zonesResponse
console.log('[NavigationPage] Loaded zones:', { count: Array.isArray(zonesData) ? zonesData.length : 0, zonesData })
// Ищем зону по model_path
if (Array.isArray(zonesData)) {
const zone = zonesData.find((z: any) => z.model_path === selectedModelPath)
if (zone) {
zoneId = zone.id
console.log('[NavigationPage] Found zone for model_path:', { modelPath: selectedModelPath, zoneId })
} else {
console.log('[NavigationPage] No zone found for model_path:', selectedModelPath)
}
}
}
} catch (e) {
console.warn('[NavigationPage] Failed to load zones for filtering:', e)
}
}
// Загружаем датчики (с фильтром по зоне если найдена)
const detectorsUrl = zoneId
? `/api/get-detectors?zone_id=${zoneId}`
: '/api/get-detectors'
console.log('[NavigationPage] Loading detectors from:', detectorsUrl)
const res = await fetch(detectorsUrl, { cache: 'no-store' })
const text = await res.text()
let payload: any
try { payload = JSON.parse(text) } catch { payload = text }
console.log('[NavigationPage] GET /api/get-detectors', { status: res.status, payload })
if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов'))
const data = payload?.data ?? payload
const detectors = (data?.detectors ?? {}) as Record<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()
}, [selectedModelPath, objectId])
const handleBackClick = () => {
router.push('/dashboard')
}
const handleDetectorMenuClick = (detector: DetectorType) => {
// Для тестов. Выбор детектора.
console.log('[NavigationPage] Selected detector click:', {
detector_id: detector.detector_id,
name: detector.name,
serial_number: detector.serial_number,
})
// Проверяем, что детектор имеет необходимые данные
if (!detector || !detector.detector_id || !detector.serial_number) {
console.warn('[NavigationPage] Invalid detector data, skipping menu display:', detector)
return
}
if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) {
closeDetectorMenu()
} else {
setSelectedDetector(detector)
setShowDetectorMenu(true)
setFocusedSensorId(detector.serial_number)
setShowAlertMenu(false)
setSelectedAlert(null)
// При открытии меню детектора - сбрасываем множественное выделение
setHighlightAllSensors(false)
}
}
const closeDetectorMenu = () => {
setShowDetectorMenu(false)
setSelectedDetector(null)
setFocusedSensorId(null)
setSelectedAlert(null)
// При закрытии меню детектора - выделяем все сенсоры снова
setHighlightAllSensors(true)
}
const handleNotificationClick = (notification: NotificationType) => {
if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) {
setShowNotificationDetectorInfo(false)
setSelectedNotification(null)
} else {
setSelectedNotification(notification)
setShowNotificationDetectorInfo(true)
}
}
const closeNotificationDetectorInfo = () => {
setShowNotificationDetectorInfo(false)
setSelectedNotification(null)
}
const closeAlertMenu = () => {
setShowAlertMenu(false)
setSelectedAlert(null)
setFocusedSensorId(null)
setSelectedDetector(null)
// При закрытии меню алерта - выделяем все сенсоры снова
setHighlightAllSensors(true)
}
const handleAlertClick = (alert: AlertType) => {
console.log('[NavigationPage] Alert clicked, focusing on detector in 3D scene:', alert)
const detector = Object.values(detectorsData.detectors).find(
d => d.detector_id === alert.detector_id
)
if (detector) {
if (selectedAlert?.id === alert.id && showAlertMenu) {
closeAlertMenu()
} else {
setSelectedAlert(alert)
setShowAlertMenu(true)
setFocusedSensorId(detector.serial_number)
setShowDetectorMenu(false)
setSelectedDetector(null)
// При открытии меню алерта - сбрасываем множественное выделение
setHighlightAllSensors(false)
console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name)
}
} else {
console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id)
}
}
const handleSensorSelection = (serialNumber: string | null) => {
if (serialNumber === null) {
setFocusedSensorId(null);
closeDetectorMenu();
closeAlertMenu();
// If we're in Sensors menu and no sensor is selected, highlight all sensors
if (showSensors) {
setHighlightAllSensors(true);
}
return;
}
if (focusedSensorId === serialNumber) {
setFocusedSensorId(null);
closeDetectorMenu();
closeAlertMenu();
// If we're in Sensors menu and deselected the current sensor, highlight all sensors
if (showSensors) {
setHighlightAllSensors(true);
}
return;
}
// При выборе конкретного сенсора - сбрасываем множественное выделение
setHighlightAllSensors(false)
const detector = Object.values(detectorsData?.detectors || {}).find(
(d) => d.serial_number === serialNumber
);
if (detector) {
// Всегда показываем меню детектора для всех датчиков
handleDetectorMenuClick(detector);
} else {
setFocusedSensorId(null);
closeDetectorMenu();
closeAlertMenu();
// If we're in Sensors menu and no valid detector found, highlight all sensors
if (showSensors) {
setHighlightAllSensors(true);
}
}
};
// Обработка focusSensorId из URL (при переходе из таблиц событий)
useEffect(() => {
if (urlFocusSensorId && isModelReady && detectorsData) {
console.log('[NavigationPage] Setting focusSensorId from URL:', urlFocusSensorId)
setFocusedSensorId(urlFocusSensorId)
setHighlightAllSensors(false)
// Автоматически открываем тултип датчика
setTimeout(() => {
handleSensorSelection(urlFocusSensorId)
}, 500) // Задержка для полной инициализации
// Очищаем URL от параметра после применения
const newUrl = new URL(window.location.href)
newUrl.searchParams.delete('focusSensorId')
window.history.replaceState({}, '', newUrl.toString())
}
}, [urlFocusSensorId, isModelReady, detectorsData])
const getStatusText = (status: string) => {
const s = (status || '').toLowerCase()
switch (s) {
case statusColors.STATUS_COLOR_CRITICAL:
case 'critical':
return 'Критический'
case statusColors.STATUS_COLOR_WARNING:
case 'warning':
return 'Предупреждение'
case statusColors.STATUS_COLOR_NORMAL:
case 'normal':
return 'Норма'
default:
return 'Неизвестно'
}
}
return (
<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

View File

@@ -119,7 +119,7 @@ export interface NavigationStore {
getActiveSidebarItem: () => number getActiveSidebarItem: () => number
// Навигация к датчику на 3D-модели // Навигация к датчику на 3D-модели
navigateToSensor: (sensorSerialNumber: string, floor: number | null, viewType: 'building' | 'floor') => Promise<string | null> navigateToSensor: (sensorSerialNumber: string, floor: number | null, viewType: 'building' | 'floor') => Promise<{ sensorSerialNumber: string; modelPath: string } | null>
} }
const useNavigationStore = create<NavigationStore>()( const useNavigationStore = create<NavigationStore>()(
@@ -399,40 +399,54 @@ const useNavigationStore = create<NavigationStore>()(
targetZone = currentZones[0] targetZone = currentZones[0]
console.log('[navigateToSensor] Building view - selected first zone:', targetZone?.name) console.log('[navigateToSensor] Building view - selected first zone:', targetZone?.name)
} else if (viewType === 'floor') { } else if (viewType === 'floor') {
// Для вида на этаже - ищем зону, где есть этот датчик // Для вида на этаже - ищем зону, где есть этот датчик (исключая order=0)
// Сначала проверяем зоны с sensors массивом // Фильтруем зоны: исключаем общий план (order=0)
for (const zone of currentZones) { const floorZones = currentZones.filter(z => z.order !== 0 && z.model_path)
if (zone.sensors && Array.isArray(zone.sensors)) {
const hasSensor = zone.sensors.some(s => console.log('[navigateToSensor] Searching in floor zones (excluding order=0):', floorZones.length)
s.serial_number === sensorSerialNumber || console.log('[navigateToSensor] Floor zones:', floorZones.map(z => ({ id: z.id, name: z.name, order: z.order, floor: z.floor })))
s.name === sensorSerialNumber console.log('[navigateToSensor] Looking for sensor:', sensorSerialNumber)
// Загружаем датчики для каждой зоны и ищем нужный
for (const zone of floorZones) {
try {
console.log(`[navigateToSensor] Checking zone: ${zone.name} (id: ${zone.id}, order: ${zone.order}, floor: ${zone.floor})`)
const res = await fetch(`/api/get-detectors?zone_id=${zone.id}`, { cache: 'no-store' })
if (!res.ok) {
console.warn(`[navigateToSensor] API request failed for zone ${zone.id}:`, res.status, res.statusText)
continue
}
const payload = await res.json()
const data = payload?.data ?? payload
const detectorsObj = (data?.detectors ?? {}) as Record<string, any>
const detectorsList = Object.values(detectorsObj)
console.log(`[navigateToSensor] Zone ${zone.name} has ${detectorsList.length} detectors:`, detectorsList.map((d: any) => d.serial_number || d.name))
// Проверяем есть ли датчик в этой зоне
const hasSensor = detectorsList.some((d: any) =>
d.serial_number === sensorSerialNumber ||
d.name === sensorSerialNumber
) )
console.log(`[navigateToSensor] Sensor ${sensorSerialNumber} found in zone ${zone.name}:`, hasSensor)
if (hasSensor) { if (hasSensor) {
targetZone = zone targetZone = zone
console.log('[navigateToSensor] Found sensor in zone:', zone.name, 'sensors:', zone.sensors.length) console.log('[navigateToSensor] ✅ FOUND! Selected zone:', zone.name, 'zoneId:', zone.id, 'model_path:', zone.model_path)
break break
} }
} } catch (e) {
} console.error('[navigateToSensor] Failed to load detectors for zone:', zone.id, e)
continue
// Если не нашли по sensors, пробуем по floor
if (!targetZone && floor !== null) {
// Ищем зоны с соответствующим floor (кроме общего вида)
const floorZones = currentZones.filter(z =>
z.floor === floor &&
z.order !== 0 &&
z.model_path
)
if (floorZones.length > 0) {
targetZone = floorZones[0]
console.log('[navigateToSensor] Found zone by floor:', targetZone.name, 'floor:', floor)
} }
} }
// Fallback на общий вид, если не нашли зону этажа // Fallback на общий вид, если не нашли зону этажа
if (!targetZone) { if (!targetZone) {
console.warn(`[navigateToSensor] No zone found with sensor ${sensorSerialNumber} or floor ${floor}, falling back to building view`) console.warn(`[navigateToSensor] No floor zone found with sensor ${sensorSerialNumber}, falling back to building view`)
targetZone = currentZones[0] targetZone = currentZones[0]
} }
} }
@@ -468,8 +482,11 @@ const useNavigationStore = create<NavigationStore>()(
zoneId: targetZone.id zoneId: targetZone.id
}) })
// Возвращаем serial_number для установки focusedSensorId в компоненте // Возвращаем объект с sensorSerialNumber и modelPath
return sensorSerialNumber return {
sensorSerialNumber,
modelPath: targetZone.model_path
}
} }
}), }),
{ {

View File

@@ -1,380 +0,0 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { Zone } from '@/app/types'
export interface DetectorType {
detector_id: number
name: string
serial_number: string
object: string
status: string
type: string
detector_type: string
location: string
floor: number
checked: boolean
notifications: Array<{
id: number
type: string
message: string
timestamp: string
acknowledged: boolean
priority: string
}>
}
interface NotificationType {
id: number
detector_id: number
detector_name: string
type: string
status: string
message: string
timestamp: string
location: string
object: string
acknowledged: boolean
priority: string
}
interface AlertType {
id: number
detector_id: number
detector_name: string
type: string
status: string
message: string
timestamp: string
location: string
object: string
acknowledged: boolean
priority: string
}
export interface NavigationStore {
currentObject: { id: string | undefined; title: string | undefined }
navigationHistory: string[]
currentSubmenu: string | null
currentModelPath: string | null
// Состояния Зон
currentZones: Zone[]
zonesCache: Record<string, Zone[]>
zonesLoading: boolean
zonesError: string | null
showMonitoring: boolean
showFloorNavigation: boolean
showNotifications: boolean
showListOfDetectors: boolean
showSensors: boolean
showSensorHighlights: boolean
selectedDetector: DetectorType | null
showDetectorMenu: boolean
selectedNotification: NotificationType | null
showNotificationDetectorInfo: boolean
selectedAlert: AlertType | null
showAlertMenu: boolean
setCurrentObject: (id: string | undefined, title: string | undefined) => void
clearCurrentObject: () => void
addToHistory: (path: string) => void
goBack: () => string | null
setCurrentModelPath: (path: string) => void
setCurrentSubmenu: (submenu: string | null) => void
clearSubmenu: () => void
// Действия с зонами
loadZones: (objectId: string) => Promise<void>
setZones: (zones: Zone[]) => void
clearZones: () => void
openMonitoring: () => void
closeMonitoring: () => void
openFloorNavigation: () => void
closeFloorNavigation: () => void
openNotifications: () => void
closeNotifications: () => void
openListOfDetectors: () => void
closeListOfDetectors: () => void
openSensors: () => void
closeSensors: () => void
toggleSensorHighlights: () => void
setSensorHighlights: (show: boolean) => void
closeAllMenus: () => void
clearSelections: () => void
setSelectedDetector: (detector: DetectorType | null) => void
setShowDetectorMenu: (show: boolean) => void
setSelectedNotification: (notification: NotificationType | null) => void
setShowNotificationDetectorInfo: (show: boolean) => void
setSelectedAlert: (alert: AlertType | null) => void
setShowAlertMenu: (show: boolean) => void
isOnNavigationPage: () => boolean
getCurrentRoute: () => string | null
getActiveSidebarItem: () => number
}
const useNavigationStore = create<NavigationStore>()(
persist(
(set, get) => ({
currentObject: {
id: undefined,
title: undefined,
},
navigationHistory: [],
currentSubmenu: null,
currentModelPath: null,
currentZones: [],
zonesCache: {},
zonesLoading: false,
zonesError: null,
showMonitoring: false,
showFloorNavigation: false,
showNotifications: false,
showListOfDetectors: false,
showSensors: false,
showSensorHighlights: true,
selectedDetector: null,
showDetectorMenu: false,
selectedNotification: null,
showNotificationDetectorInfo: false,
selectedAlert: null,
showAlertMenu: false,
setCurrentObject: (id: string | undefined, title: string | undefined) =>
set({ currentObject: { id, title } }),
clearCurrentObject: () =>
set({ currentObject: { id: undefined, title: undefined } }),
setCurrentModelPath: (path: string) => set({ currentModelPath: path }),
addToHistory: (path: string) => {
const { navigationHistory } = get()
const newHistory = [...navigationHistory, path]
if (newHistory.length > 10) {
newHistory.shift()
}
set({ navigationHistory: newHistory })
},
goBack: () => {
const { navigationHistory } = get()
if (navigationHistory.length > 1) {
const newHistory = [...navigationHistory]
newHistory.pop()
const previousPage = newHistory.pop()
set({ navigationHistory: newHistory })
return previousPage || null
}
return null
},
setCurrentSubmenu: (submenu: string | null) =>
set({ currentSubmenu: submenu }),
clearSubmenu: () =>
set({ currentSubmenu: null }),
loadZones: async (objectId: string) => {
const cache = get().zonesCache
const cached = cache[objectId]
const hasCached = Array.isArray(cached) && cached.length > 0
if (hasCached) {
// Показываем кэшированные зоны сразу, но обновляем в фоне
set({ currentZones: cached, zonesLoading: true, zonesError: null })
} else {
set({ zonesLoading: true, zonesError: null })
}
try {
const res = await fetch(`/api/get-zones?objectId=${encodeURIComponent(objectId)}`, { cache: 'no-store' })
const text = await res.text()
let payload: string | Record<string, unknown>
try { payload = JSON.parse(text) } catch { payload = text }
if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error as string || 'Не удалось получить зоны'))
const zones: Zone[] = typeof payload === 'string' ? [] :
Array.isArray(payload?.data) ? payload.data as Zone[] :
(payload?.data && typeof payload.data === 'object' && 'zones' in payload.data ? (payload.data as { zones?: Zone[] }).zones :
payload?.zones ? payload.zones as Zone[] : []) || []
const normalized = zones.map((z) => ({
...z,
image_path: z.image_path ?? null,
}))
set((state) => ({
currentZones: normalized,
zonesCache: { ...state.zonesCache, [objectId]: normalized },
zonesLoading: false,
zonesError: null,
}))
} catch (e: unknown) {
set({ zonesLoading: false, zonesError: (e as Error)?.message || 'Ошибка при загрузке зон' })
}
},
setZones: (zones: Zone[]) => set({ currentZones: zones }),
clearZones: () => set({ currentZones: [] }),
openMonitoring: () => {
set({
showMonitoring: true,
showFloorNavigation: false,
showNotifications: false,
showListOfDetectors: false,
currentSubmenu: 'monitoring',
showDetectorMenu: false,
selectedDetector: null,
showNotificationDetectorInfo: false,
selectedNotification: null,
zonesError: null // Очищаем ошибку зон при открытии мониторинга
})
const objId = get().currentObject.id
if (objId) {
// Вызываем загрузку зон сразу, но обновляем в фоне
get().loadZones(objId)
}
},
closeMonitoring: () => set({
showMonitoring: false,
currentSubmenu: null
}),
openFloorNavigation: () => set({
showFloorNavigation: true,
showMonitoring: false,
showNotifications: false,
showListOfDetectors: false,
currentSubmenu: 'floors',
showNotificationDetectorInfo: false,
selectedNotification: null
}),
closeFloorNavigation: () => set({
showFloorNavigation: false,
showDetectorMenu: false,
selectedDetector: null,
currentSubmenu: null
}),
openNotifications: () => set({
showNotifications: true,
showMonitoring: false,
showFloorNavigation: false,
showListOfDetectors: false,
currentSubmenu: 'notifications',
showDetectorMenu: false,
selectedDetector: null
}),
closeNotifications: () => set({
showNotifications: false,
showNotificationDetectorInfo: false,
selectedNotification: null,
currentSubmenu: null
}),
openListOfDetectors: () => set({
showListOfDetectors: true,
showMonitoring: false,
showFloorNavigation: false,
showNotifications: false,
currentSubmenu: 'detectors',
showDetectorMenu: false,
selectedDetector: null,
showNotificationDetectorInfo: false,
selectedNotification: null
}),
closeListOfDetectors: () => set({
showListOfDetectors: false,
showDetectorMenu: false,
selectedDetector: null,
currentSubmenu: null
}),
openSensors: () => set({
showSensors: true,
showMonitoring: false,
showFloorNavigation: false,
showNotifications: false,
showListOfDetectors: false,
currentSubmenu: 'sensors',
showDetectorMenu: false,
selectedDetector: null,
showNotificationDetectorInfo: false,
selectedNotification: null
}),
closeSensors: () => set({
showSensors: false,
showDetectorMenu: false,
selectedDetector: null,
currentSubmenu: null
}),
toggleSensorHighlights: () => set((state) => ({ showSensorHighlights: !state.showSensorHighlights })),
setSensorHighlights: (show: boolean) => set({ showSensorHighlights: show }),
closeAllMenus: () => {
set({
showMonitoring: false,
showFloorNavigation: false,
showNotifications: false,
showListOfDetectors: false,
showSensors: false,
currentSubmenu: null,
});
get().clearSelections();
},
clearSelections: () => set({
selectedDetector: null,
showDetectorMenu: false,
selectedAlert: null,
showAlertMenu: false,
}),
setSelectedDetector: (detector: DetectorType | null) => set({ selectedDetector: detector }),
setShowDetectorMenu: (show: boolean) => set({ showDetectorMenu: show }),
setSelectedNotification: (notification: NotificationType | null) => set({ selectedNotification: notification }),
setShowNotificationDetectorInfo: (show: boolean) => set({ showNotificationDetectorInfo: show }),
setSelectedAlert: (alert: AlertType | null) => set({ selectedAlert: alert }),
setShowAlertMenu: (show: boolean) => set({ showAlertMenu: show }),
isOnNavigationPage: () => {
const { navigationHistory } = get()
const currentRoute = navigationHistory[navigationHistory.length - 1]
return currentRoute === '/navigation'
},
getCurrentRoute: () => {
const { navigationHistory } = get()
return navigationHistory[navigationHistory.length - 1] || null
},
getActiveSidebarItem: () => {
const { showMonitoring, showFloorNavigation, showNotifications, showListOfDetectors, showSensors } = get()
if (showMonitoring) return 3 // Зоны Мониторинга
if (showFloorNavigation) return 4 // Навигация по этажам
if (showNotifications) return 5 // Уведомления
if (showListOfDetectors) return 7 // Список датчиков
if (showSensors) return 8 // Сенсоры
return 2 // Навигация (базовая)
}
}),
{
name: 'navigation-store',
}
)
)
export default useNavigationStore

View File

@@ -68,6 +68,7 @@ export interface NavigationStore {
showNotifications: boolean showNotifications: boolean
showListOfDetectors: boolean showListOfDetectors: boolean
showSensors: boolean showSensors: boolean
showSensorHighlights: boolean
selectedDetector: DetectorType | null selectedDetector: DetectorType | null
showDetectorMenu: boolean showDetectorMenu: boolean
@@ -100,6 +101,8 @@ export interface NavigationStore {
closeListOfDetectors: () => void closeListOfDetectors: () => void
openSensors: () => void openSensors: () => void
closeSensors: () => void closeSensors: () => void
toggleSensorHighlights: () => void
setSensorHighlights: (show: boolean) => void
closeAllMenus: () => void closeAllMenus: () => void
clearSelections: () => void clearSelections: () => void
@@ -114,6 +117,9 @@ export interface NavigationStore {
isOnNavigationPage: () => boolean isOnNavigationPage: () => boolean
getCurrentRoute: () => string | null getCurrentRoute: () => string | null
getActiveSidebarItem: () => number getActiveSidebarItem: () => number
// Навигация к датчику на 3D-модели
navigateToSensor: (sensorSerialNumber: string, floor: number | null, viewType: 'building' | 'floor') => Promise<string | null>
} }
const useNavigationStore = create<NavigationStore>()( const useNavigationStore = create<NavigationStore>()(
@@ -137,6 +143,7 @@ const useNavigationStore = create<NavigationStore>()(
showNotifications: false, showNotifications: false,
showListOfDetectors: false, showListOfDetectors: false,
showSensors: false, showSensors: false,
showSensorHighlights: true,
selectedDetector: null, selectedDetector: null,
showDetectorMenu: false, showDetectorMenu: false,
@@ -316,6 +323,9 @@ const useNavigationStore = create<NavigationStore>()(
currentSubmenu: null currentSubmenu: null
}), }),
toggleSensorHighlights: () => set((state) => ({ showSensorHighlights: !state.showSensorHighlights })),
setSensorHighlights: (show: boolean) => set({ showSensorHighlights: show }),
closeAllMenus: () => { closeAllMenus: () => {
set({ set({
showMonitoring: false, showMonitoring: false,
@@ -361,6 +371,105 @@ const useNavigationStore = create<NavigationStore>()(
if (showListOfDetectors) return 7 // Список датчиков if (showListOfDetectors) return 7 // Список датчиков
if (showSensors) return 8 // Сенсоры if (showSensors) return 8 // Сенсоры
return 2 // Навигация (базовая) return 2 // Навигация (базовая)
},
// Навигация к датчику на 3D-модели
navigateToSensor: async (sensorSerialNumber: string, floor: number | null, viewType: 'building' | 'floor') => {
const { currentObject, loadZones } = get()
if (!currentObject.id) {
console.error('[navigateToSensor] No current object selected')
return null
}
// Загружаем зоны для объекта (из кэша или API)
await loadZones(currentObject.id)
const { currentZones } = get()
if (!currentZones || currentZones.length === 0) {
console.error('[navigateToSensor] No zones available for object', currentObject.id)
return null
}
let targetZone: Zone | undefined
if (viewType === 'building') {
// Для общего вида здания - ищем самую верхнюю зону (первую в списке)
targetZone = currentZones[0]
console.log('[navigateToSensor] Building view - selected first zone:', targetZone?.name)
} else if (viewType === 'floor') {
// Для вида на этаже - ищем зону, где есть этот датчик
// Сначала проверяем зоны с sensors массивом
for (const zone of currentZones) {
if (zone.sensors && Array.isArray(zone.sensors)) {
const hasSensor = zone.sensors.some(s =>
s.serial_number === sensorSerialNumber ||
s.name === sensorSerialNumber
)
if (hasSensor) {
targetZone = zone
console.log('[navigateToSensor] Found sensor in zone:', zone.name, 'sensors:', zone.sensors.length)
break
}
}
}
// Если не нашли по sensors, пробуем по floor
if (!targetZone && floor !== null) {
// Ищем зоны с соответствующим floor (кроме общего вида)
const floorZones = currentZones.filter(z =>
z.floor === floor &&
z.order !== 0 &&
z.model_path
)
if (floorZones.length > 0) {
targetZone = floorZones[0]
console.log('[navigateToSensor] Found zone by floor:', targetZone.name, 'floor:', floor)
}
}
// Fallback на общий вид, если не нашли зону этажа
if (!targetZone) {
console.warn(`[navigateToSensor] No zone found with sensor ${sensorSerialNumber} or floor ${floor}, falling back to building view`)
targetZone = currentZones[0]
}
}
if (!targetZone || !targetZone.model_path) {
console.error('[navigateToSensor] No valid zone with model_path found')
return null
}
// Устанавливаем состояние для навигации
set({
currentModelPath: targetZone.model_path,
// Открываем Зоны контроля (Monitoring) - она автоматически закроется после загрузки модели
showMonitoring: true,
// Закрываем остальные меню
showFloorNavigation: false,
showNotifications: false,
showListOfDetectors: false,
// НЕ закрываем showSensors - оставляем как есть для подсветки датчиков
// showSensors: false, <- Убрали!
showDetectorMenu: false,
showAlertMenu: false,
selectedDetector: null,
selectedAlert: null,
})
console.log('[navigateToSensor] Navigation prepared:', {
sensorSerialNumber,
floor,
viewType,
modelPath: targetZone.model_path,
zoneName: targetZone.name,
zoneId: targetZone.id
})
// Возвращаем serial_number для установки focusedSensorId в компоненте
return sensorSerialNumber
} }
}), }),
{ {

View File

@@ -61,14 +61,14 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
return return
} }
const sensorSerialNumber = await navigateToSensor( const result = await navigateToSensor(
sensorId, sensorId,
alert.floor || null, alert.floor || null,
viewType viewType
) )
if (sensorSerialNumber) { if (result) {
router.push(`/navigation?focusSensorId=${encodeURIComponent(sensorSerialNumber)}`) router.push(`/navigation?focusSensorId=${encodeURIComponent(result.sensorSerialNumber)}&modelPath=${encodeURIComponent(result.modelPath)}`)
} }
} }

View File

@@ -103,7 +103,6 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th> <th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Статус</th> <th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Статус</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th> <th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Местоположение</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Приоритет</th> <th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Приоритет</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Подтверждено</th> <th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Подтверждено</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Время</th> <th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Время</th>
@@ -133,9 +132,6 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
<td style={interRegularStyle} className="py-4 text-sm text-white"> <td style={interRegularStyle} className="py-4 text-sm text-white">
{item.message} {item.message}
</td> </td>
<td style={interRegularStyle} className="py-4 text-sm text-white">
{item.location || '-'}
</td>
<td className="py-4"> <td className="py-4">
<span <span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white" className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
@@ -194,7 +190,7 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
))} ))}
{filteredAlerts.length === 0 && ( {filteredAlerts.length === 0 && (
<tr> <tr>
<td colSpan={8} className="py-8 text-center text-gray-400"> <td colSpan={7} className="py-8 text-center text-gray-400">
Записей не найдено Записей не найдено
</td> </td>
</tr> </tr>

View File

@@ -129,15 +129,15 @@ const Dashboard: React.FC = () => {
return return
} }
const sensorSerialNumber = await navigateToSensor( const result = await navigateToSensor(
sensorId, sensorId,
alert.floor || null, alert.floor || null,
viewType viewType
) )
if (sensorSerialNumber) { if (result) {
// Переходим на страницу навигации с параметром focusSensorId // Переходим на страницу навигации с параметрами focusSensorId и modelPath
router.push(`/navigation?focusSensorId=${encodeURIComponent(sensorSerialNumber)}`) router.push(`/navigation?focusSensorId=${encodeURIComponent(result.sensorSerialNumber)}&modelPath=${encodeURIComponent(result.modelPath)}`)
} }
} }

View File

@@ -8,11 +8,11 @@ import useNavigationStore from '../../app/store/navigationStore'
import ChartCard from './ChartCard' import ChartCard from './ChartCard'
import AreaChart from './AreaChart' import AreaChart from './AreaChart'
import BarChart from './BarChart' import BarChart from './BarChart'
import { aggregateChartDataByDays } from '../../lib/chartDataAggregator' import { aggregateChartDataByDays, aggregateAlertsBySeverity } from '../../lib/chartDataAggregator'
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const router = useRouter() const router = useRouter()
const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications } = useNavigationStore() const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications, navigateToSensor } = useNavigationStore()
const objectTitle = currentObject?.title const objectTitle = currentObject?.title
const [dashboardAlerts, setDashboardAlerts] = useState<any[]>([]) const [dashboardAlerts, setDashboardAlerts] = useState<any[]>([])
@@ -120,6 +120,27 @@ const Dashboard: React.FC = () => {
router.push('/navigation') router.push('/navigation')
} }
const handleGoTo3D = async (alert: any, viewType: 'building' | 'floor') => {
// Используем alert.name как идентификатор датчика (например, "GA-11")
const sensorId = alert.serial_number || alert.name
if (!sensorId) {
console.warn('[Dashboard] Alert missing sensor identifier:', alert)
return
}
const sensorSerialNumber = await navigateToSensor(
sensorId,
alert.floor || null,
viewType
)
if (sensorSerialNumber) {
// Переходим на страницу навигации с параметром focusSensorId
router.push(`/navigation?focusSensorId=${encodeURIComponent(sensorSerialNumber)}`)
}
}
const handleSensorTypeChange = (sensorType: string) => { const handleSensorTypeChange = (sensorType: string) => {
setSelectedSensorType(sensorType) setSelectedSensorType(sensorType)
} }
@@ -132,10 +153,10 @@ const Dashboard: React.FC = () => {
setSelectedTablePeriod(period) setSelectedTablePeriod(period)
} }
// Агрегируем данные графика в зависимости от периода // Агрегируем данные графика в зависимости от периода с разделением по severity
const chartData = useMemo(() => { const chartData = useMemo(() => {
return aggregateChartDataByDays(rawChartData, selectedChartPeriod) return aggregateAlertsBySeverity(dashboardAlerts, selectedChartPeriod)
}, [rawChartData, selectedChartPeriod]) }, [dashboardAlerts, selectedChartPeriod])
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 } const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 } const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
@@ -228,7 +249,7 @@ const Dashboard: React.FC = () => {
<ChartCard <ChartCard
title="Статистика" title="Статистика"
> >
<BarChart data={chartData?.map((d: any) => ({ value: d.value, label: d.label }))} /> <BarChart data={chartData} />
</ChartCard> </ChartCard>
</div> </div>
</div> </div>
@@ -266,6 +287,7 @@ const Dashboard: React.FC = () => {
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Серьезность</th> <th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Серьезность</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Дата</th> <th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Дата</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Решен</th> <th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Решен</th>
<th style={interSemiboldStyle} className="text-center text-white text-sm py-3">3D Вид</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -287,6 +309,28 @@ const Dashboard: React.FC = () => {
) )
} }
</td> </td>
<td className="py-3">
<div className="flex items-center justify-center gap-2">
<button
onClick={() => handleGoTo3D(alert, 'building')}
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
title="Показать на общей модели"
>
<img src="/icons/Building3D.png" alt="Здание" className="w-5 h-5 opacity-70 group-hover:opacity-100" />
</button>
<button
onClick={() => handleGoTo3D(alert, 'floor')}
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
title="Показать на этаже"
>
<img
src="/icons/Floor3D.png"
alt="Этаж"
className="w-5 h-5 opacity-70 group-hover:opacity-100"
/>
</button>
</div>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -1,368 +0,0 @@
'use client'
import React, { useEffect, useState, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import Sidebar from '../ui/Sidebar'
import AnimatedBackground from '../ui/AnimatedBackground'
import useNavigationStore from '../../app/store/navigationStore'
import ChartCard from './ChartCard'
import AreaChart from './AreaChart'
import BarChart from './BarChart'
import { aggregateChartDataByDays } from '../../lib/chartDataAggregator'
const Dashboard: React.FC = () => {
const router = useRouter()
const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications, navigateToSensor } = useNavigationStore()
const objectTitle = currentObject?.title
const [dashboardAlerts, setDashboardAlerts] = useState<any[]>([])
const [rawChartData, setRawChartData] = useState<{ timestamp: string; value: number }[]>([])
const [sensorTypes] = useState<Array<{code: string, name: string}>>([
{ code: '', name: 'Все датчики' },
{ code: 'GA', name: 'Инклинометр' },
{ code: 'PE', name: 'Танзометр' },
{ code: 'GLE', name: 'Гидроуровень' }
])
const [selectedSensorType, setSelectedSensorType] = useState<string>('')
const [selectedChartPeriod, setSelectedChartPeriod] = useState<string>('168')
const [selectedTablePeriod, setSelectedTablePeriod] = useState<string>('168')
useEffect(() => {
const loadDashboard = async () => {
try {
const params = new URLSearchParams()
params.append('time_period', selectedChartPeriod)
const res = await fetch(`/api/get-dashboard?${params.toString()}`, { cache: 'no-store' })
if (!res.ok) return
const payload = await res.json()
console.log('[Dashboard] GET /api/get-dashboard', { status: res.status, payload })
let tableData = payload?.data?.table_data ?? []
tableData = Array.isArray(tableData) ? tableData : []
if (objectTitle) {
tableData = tableData.filter((a: any) => a.object === objectTitle)
}
if (selectedSensorType && selectedSensorType !== '') {
tableData = tableData.filter((a: any) => {
return a.detector_type?.toLowerCase() === selectedSensorType.toLowerCase()
})
}
setDashboardAlerts(tableData as any[])
const cd = Array.isArray(payload?.data?.chart_data) ? payload.data.chart_data : []
setRawChartData(cd as any[])
} catch (e) {
console.error('Failed to load dashboard:', e)
}
}
loadDashboard()
}, [objectTitle, selectedChartPeriod, selectedSensorType])
// Отдельный эффект для загрузки таблицы по выбранному периоду
useEffect(() => {
const loadTableData = async () => {
try {
const params = new URLSearchParams()
params.append('time_period', selectedTablePeriod)
const res = await fetch(`/api/get-dashboard?${params.toString()}`, { cache: 'no-store' })
if (!res.ok) return
const payload = await res.json()
console.log('[Dashboard] GET /api/get-dashboard (table)', { status: res.status, payload })
let tableData = payload?.data?.table_data ?? []
tableData = Array.isArray(tableData) ? tableData : []
if (objectTitle) {
tableData = tableData.filter((a: any) => a.object === objectTitle)
}
if (selectedSensorType && selectedSensorType !== '') {
tableData = tableData.filter((a: any) => {
return a.detector_type?.toLowerCase() === selectedSensorType.toLowerCase()
})
}
setDashboardAlerts(tableData as any[])
} catch (e) {
console.error('Failed to load table data:', e)
}
}
loadTableData()
}, [objectTitle, selectedTablePeriod, selectedSensorType])
const handleBackClick = () => {
router.push('/objects')
}
const filteredAlerts = dashboardAlerts.filter((alert: any) => {
if (selectedSensorType === '') return true
return alert.detector_type?.toLowerCase() === selectedSensorType.toLowerCase()
})
// Статусы
const statusCounts = filteredAlerts.reduce((acc: { critical: number; warning: number; normal: number }, a: any) => {
if (a.severity === 'critical') acc.critical++
else if (a.severity === 'warning') acc.warning++
else acc.normal++
return acc
}, { critical: 0, warning: 0, normal: 0 })
const handleNavigationClick = () => {
closeMonitoring()
closeFloorNavigation()
closeNotifications()
setCurrentSubmenu(null)
router.push('/navigation')
}
const handleGoTo3D = async (alert: any, viewType: 'building' | 'floor') => {
// Используем alert.name как идентификатор датчика (например, "GA-11")
const sensorId = alert.serial_number || alert.name
if (!sensorId) {
console.warn('[Dashboard] Alert missing sensor identifier:', alert)
return
}
const sensorSerialNumber = await navigateToSensor(
sensorId,
alert.floor || null,
viewType
)
if (sensorSerialNumber) {
// Переходим на страницу навигации с параметром focusSensorId
router.push(`/navigation?focusSensorId=${encodeURIComponent(sensorSerialNumber)}`)
}
}
const handleSensorTypeChange = (sensorType: string) => {
setSelectedSensorType(sensorType)
}
const handleChartPeriodChange = (period: string) => {
setSelectedChartPeriod(period)
}
const handleTablePeriodChange = (period: string) => {
setSelectedTablePeriod(period)
}
// Агрегируем данные графика в зависимости от периода
const chartData = useMemo(() => {
return aggregateChartDataByDays(rawChartData, selectedChartPeriod)
}, [rawChartData, selectedChartPeriod])
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
return (
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
<AnimatedBackground />
<div className="relative z-20">
<Sidebar
activeItem={1} // Dashboard
/>
</div>
<div className="relative z-10 flex-1 flex flex-col">
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
<div className="flex items-center gap-4">
<button
onClick={handleBackClick}
className="text-gray-400 hover:text-white transition-colors"
aria-label="Назад к объектам"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<nav className="flex items-center gap-2 text-sm">
<span className="text-gray-400">Объекты</span>
<span className="text-gray-600">/</span>
<span className="text-white">{objectTitle || 'Объект'}</span>
</nav>
</div>
</header>
<div className="flex-1 p-6 overflow-auto">
<div className="mb-6">
<h1 style={interSemiboldStyle} className="text-white text-2xl mb-6">{objectTitle || 'Объект'}</h1>
<div className="flex items-center gap-3 mb-6">
<div className="relative">
<select
value={selectedSensorType}
onChange={(e) => handleSensorTypeChange(e.target.value)}
className="flex items-center gap-6 rounded-[10px] px-4 py-[18px] bg-[rgb(22,24,36)] text-white appearance-none pr-8"
>
{sensorTypes.map((type) => (
<option key={type.code} value={type.code}>
{type.name}
</option>
))}
</select>
<svg className="w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
<div className="flex items-center gap-3 ml-auto">
<button
onClick={handleNavigationClick}
className="rounded-[10px] px-4 py-[18px] bg-gray-600 text-gray-300 hover:bg-[rgb(22,24,36)] hover:text-white transition-colors"
>
<span className="text-sm font-medium">Навигация</span>
</button>
<div className="relative">
<select
value={selectedChartPeriod}
onChange={(e) => handleChartPeriodChange(e.target.value)}
className="flex items-center gap-2 bg-[rgb(22,24,36)] rounded-lg px-3 py-2 text-white appearance-none pr-8"
>
<option value="24">День</option>
<option value="72">3 дня</option>
<option value="168">Неделя</option>
<option value="720">Месяц</option>
</select>
<svg className="w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
{/* Карты-графики */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px]">
<ChartCard
title="Показатель"
>
<AreaChart data={chartData} />
</ChartCard>
<ChartCard
title="Статистика"
>
<BarChart data={chartData?.map((d: any) => ({ value: d.value, label: d.label }))} />
</ChartCard>
</div>
</div>
{/* Список детекторов */}
<div>
<div>
<div className="flex items-center justify-between mb-6">
<h2 style={interSemiboldStyle} className="text-white text-2xl">Тренды</h2>
<div className="relative">
<select
value={selectedTablePeriod}
onChange={(e) => handleTablePeriodChange(e.target.value)}
className="bg-[#161824] rounded-lg px-3 py-2 flex items-center gap-2 text-white appearance-none pr-8"
>
<option value="24">День</option>
<option value="72">3 дня</option>
<option value="168">Неделя</option>
<option value="720">Месяц</option>
</select>
<svg className="w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</div>
</div>
{/* Таблица */}
<div className="bg-[#161824] rounded-[20px] p-6">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-700">
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Серьезность</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Дата</th>
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Решен</th>
<th style={interSemiboldStyle} className="text-center text-white text-sm py-3">3D Вид</th>
</tr>
</thead>
<tbody>
{filteredAlerts.map((alert: any) => (
<tr key={alert.id} className="border-b border-gray-800">
<td style={interRegularStyle} className="py-3 text-white text-sm">{alert.name}</td>
<td style={interRegularStyle} className="py-3 text-gray-300 text-sm">{alert.message}</td>
<td className="py-3">
<span style={interRegularStyle} className={`text-sm ${alert.severity === 'critical' ? 'text-red-500' : alert.severity === 'warning' ? 'text-orange-500' : 'text-green-500'}`}>
{alert.severity === 'critical' ? 'Критическое' : alert.severity === 'warning' ? 'Предупреждение' : 'Норма'}
</span>
</td>
<td style={interRegularStyle} className="py-3 text-gray-400 text-sm">{new Date(alert.created_at).toLocaleString()}</td>
<td className="py-3">
{alert.resolved ? (
<span style={interRegularStyle} className="text-sm text-green-500">Да</span>
) : (
<span style={interRegularStyle} className="text-sm text-gray-500">Нет</span>
)
}
</td>
<td className="py-3">
<div className="flex items-center justify-center gap-2">
<button
onClick={() => handleGoTo3D(alert, 'building')}
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
title="Показать на общей модели"
>
<img src="/icons/Building3D.png" alt="Здание" className="w-5 h-5 opacity-70 group-hover:opacity-100" />
</button>
<button
onClick={() => handleGoTo3D(alert, 'floor')}
className="p-1.5 rounded hover:bg-blue-600/20 transition-colors group"
title="Показать на этаже"
>
<img
src="/icons/Floor3D.png"
alt="Этаж"
className="w-5 h-5 opacity-70 group-hover:opacity-100"
/>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Статистика */}
<div className="mt-6 grid grid-cols-4 gap-4">
<div className="text-center">
<div style={interSemiboldStyle} className="text-2xl text-white">{filteredAlerts.length}</div>
<div style={interRegularStyle} className="text-sm text-gray-400">Всего</div>
</div>
<div className="text-center">
<div style={interSemiboldStyle} className="text-2xl text-green-500">{statusCounts.normal}</div>
<div style={interRegularStyle} className="text-sm text-gray-400">Норма</div>
</div>
<div className="text-center">
<div style={interSemiboldStyle} className="text-2xl text-orange-500">{statusCounts.warning}</div>
<div style={interRegularStyle} className="text-sm text-gray-400">Предупреждения</div>
</div>
<div className="text-center">
<div style={interSemiboldStyle} className="text-2xl text-red-500">{statusCounts.critical}</div>
<div style={interRegularStyle} className="text-sm text-gray-400">Критические</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default Dashboard

View File

@@ -0,0 +1,317 @@
'use client'
import React, { useEffect, useRef, useState } from 'react'
import { AbstractMesh, Vector3 } from '@babylonjs/core'
interface Canvas2DPlanProps {
meshes: AbstractMesh[]
sensorStatusMap: Record<string, string>
onClose: () => void
onSensorClick?: (sensorId: string) => void
}
interface Sensor2D {
id: string
x: number
y: number
status: string
}
const Canvas2DPlan: React.FC<Canvas2DPlanProps> = ({
meshes,
sensorStatusMap,
onClose,
onSensorClick,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [sensors, setSensors] = useState<Sensor2D[]>([])
const [hoveredSensor, setHoveredSensor] = useState<string | null>(null)
const [scale, setScale] = useState(10)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
// Извлечение датчиков из mesh'ей
useEffect(() => {
const extractedSensors: Sensor2D[] = []
console.log('[Canvas2DPlan] Extracting sensors from meshes:', meshes.length)
console.log('[Canvas2DPlan] sensorStatusMap:', sensorStatusMap)
let meshesWithMetadata = 0
let meshesWithSensorID = 0
let meshesInStatusMap = 0
meshes.forEach((mesh, index) => {
if (mesh.metadata) {
meshesWithMetadata++
if (index < 3) {
console.log(`[Canvas2DPlan] Sample mesh[${index}] metadata:`, mesh.metadata)
}
}
const sensorId = mesh.metadata?.Sensor_ID
if (sensorId) {
meshesWithSensorID++
if (index < 3) {
console.log(`[Canvas2DPlan] Sample mesh[${index}] Sensor_ID:`, sensorId, 'in map?', !!sensorStatusMap[sensorId])
}
}
if (sensorId && sensorStatusMap[sensorId]) {
meshesInStatusMap++
const position = mesh.getAbsolutePosition()
extractedSensors.push({
id: sensorId,
x: position.x,
y: position.z, // Используем Z как Y для вида сверху
status: sensorStatusMap[sensorId],
})
}
})
console.log('[Canvas2DPlan] Meshes with metadata:', meshesWithMetadata)
console.log('[Canvas2DPlan] Meshes with Sensor_ID:', meshesWithSensorID)
console.log('[Canvas2DPlan] Meshes in statusMap:', meshesInStatusMap)
console.log('[Canvas2DPlan] Extracted sensors:', extractedSensors.length, extractedSensors)
setSensors(extractedSensors)
// Автоматическое центрирование
if (extractedSensors.length > 0 && canvasRef.current) {
const minX = Math.min(...extractedSensors.map((s) => s.x))
const maxX = Math.max(...extractedSensors.map((s) => s.x))
const minY = Math.min(...extractedSensors.map((s) => s.y))
const maxY = Math.max(...extractedSensors.map((s) => s.y))
const centerX = (minX + maxX) / 2
const centerY = (minY + maxY) / 2
const canvas = canvasRef.current
setOffset({
x: canvas.width / 2 - centerX * scale,
y: canvas.height / 2 - centerY * scale,
})
}
}, [meshes, sensorStatusMap, scale])
// Рендеринг canvas
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
// Очистка
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Фон
ctx.fillStyle = '#0e111a'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Сетка
ctx.strokeStyle = '#1a1d2e'
ctx.lineWidth = 1
const gridSize = 50
for (let x = 0; x < canvas.width; x += gridSize) {
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, canvas.height)
ctx.stroke()
}
for (let y = 0; y < canvas.height; y += gridSize) {
ctx.beginPath()
ctx.moveTo(0, y)
ctx.lineTo(canvas.width, y)
ctx.stroke()
}
// Рисуем датчики
sensors.forEach((sensor) => {
const x = sensor.x * scale + offset.x
const y = sensor.y * scale + offset.y
// Определяем цвет по статусу
let color = '#6b7280' // gray
if (sensor.status === 'critical') color = '#ef4444' // red
else if (sensor.status === 'warning') color = '#f59e0b' // amber
else if (sensor.status === 'normal') color = '#10b981' // green
// Внешний круг (подсветка при hover)
if (hoveredSensor === sensor.id) {
ctx.fillStyle = color + '40'
ctx.beginPath()
ctx.arc(x, y, 20, 0, Math.PI * 2)
ctx.fill()
}
// Основной круг датчика
ctx.fillStyle = color
ctx.beginPath()
ctx.arc(x, y, 8, 0, Math.PI * 2)
ctx.fill()
// Обводка
ctx.strokeStyle = '#ffffff'
ctx.lineWidth = 2
ctx.stroke()
// Подпись
ctx.fillStyle = '#ffffff'
ctx.font = '12px Inter, sans-serif'
ctx.textAlign = 'center'
ctx.fillText(sensor.id, x, y - 15)
})
// Легенда
const legendX = 20
const legendY = canvas.height - 80
ctx.fillStyle = '#161824cc'
ctx.fillRect(legendX - 10, legendY - 10, 180, 70)
const statuses = [
{ label: 'Критический', color: '#ef4444' },
{ label: 'Предупреждение', color: '#f59e0b' },
{ label: 'Нормальный', color: '#10b981' },
]
statuses.forEach((status, index) => {
const y = legendY + index * 20
ctx.fillStyle = status.color
ctx.beginPath()
ctx.arc(legendX, y, 6, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#ffffff'
ctx.font = '12px Inter, sans-serif'
ctx.textAlign = 'left'
ctx.fillText(status.label, legendX + 15, y + 4)
})
}, [sensors, scale, offset, hoveredSensor])
// Обработка клика
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const clickX = e.clientX - rect.left
const clickY = e.clientY - rect.top
// Проверяем клик по датчику
for (const sensor of sensors) {
const x = sensor.x * scale + offset.x
const y = sensor.y * scale + offset.y
const distance = Math.sqrt((clickX - x) ** 2 + (clickY - y) ** 2)
if (distance <= 10) {
onSensorClick?.(sensor.id)
return
}
}
}
// Обработка hover
const handleCanvasMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (isDragging) {
const dx = e.clientX - dragStart.x
const dy = e.clientY - dragStart.y
setOffset((prev) => ({ x: prev.x + dx, y: prev.y + dy }))
setDragStart({ x: e.clientX, y: e.clientY })
return
}
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const mouseX = e.clientX - rect.left
const mouseY = e.clientY - rect.top
let foundSensor: string | null = null
for (const sensor of sensors) {
const x = sensor.x * scale + offset.x
const y = sensor.y * scale + offset.y
const distance = Math.sqrt((mouseX - x) ** 2 + (mouseY - y) ** 2)
if (distance <= 10) {
foundSensor = sensor.id
break
}
}
setHoveredSensor(foundSensor)
}
// Обработка zoom
const handleWheel = (e: React.WheelEvent<HTMLCanvasElement>) => {
e.preventDefault()
const delta = e.deltaY > 0 ? 0.9 : 1.1
setScale((prev) => Math.max(1, Math.min(50, prev * delta)))
}
// Обработка drag
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
setIsDragging(true)
setDragStart({ x: e.clientX, y: e.clientY })
}
const handleMouseUp = () => {
setIsDragging(false)
}
return (
<div className="fixed inset-0 z-[100] bg-black/80 flex items-center justify-center p-4">
<div className="relative bg-[#161824] rounded-lg shadow-2xl border border-white/10 p-6 w-full h-full max-w-[1400px] max-h-[900px] flex flex-col">
{/* Заголовок */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-white">2D План-схема</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Canvas */}
<div className="flex-1 min-h-0">
<canvas
ref={canvasRef}
width={1200}
height={700}
onClick={handleCanvasClick}
onMouseMove={handleCanvasMove}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
className="border border-white/10 rounded cursor-move w-full h-full"
style={{ cursor: isDragging ? 'grabbing' : 'grab', maxHeight: '700px' }}
/>
</div>
{/* Подсказка */}
<div className="mt-4 text-sm text-gray-400 text-center">
<p>Колесико мыши - масштаб | Перетаскивание - перемещение | Клик по датчику - подробности</p>
</div>
</div>
</div>
)
}
export default Canvas2DPlan

View File

@@ -0,0 +1,983 @@
'use client'
import React, { useEffect, useRef, useState } from 'react'
import {
Engine,
Scene,
Vector3,
HemisphericLight,
ArcRotateCamera,
Color3,
Color4,
AbstractMesh,
Nullable,
HighlightLayer,
Animation,
CubicEase,
EasingFunction,
ImportMeshAsync,
PointerEventTypes,
PointerInfo,
Matrix,
Ray,
} from '@babylonjs/core'
import '@babylonjs/loaders'
import SceneToolbar from './SceneToolbar';
import LoadingSpinner from '../ui/LoadingSpinner'
import useNavigationStore from '@/app/store/navigationStore'
import {
getSensorIdFromMesh,
collectSensorMeshes,
applyHighlightToMeshes,
statusToColor3,
} from './sensorHighlight'
import {
computeSensorOverlayCircles,
hexWithAlpha,
} from './sensorHighlightOverlay'
export interface ModelViewerProps {
modelPath: string
onSelectModel: (path: string) => void;
onModelLoaded?: (modelData: {
meshes: AbstractMesh[]
boundingBox: {
min: { x: number; y: number; z: number }
max: { x: number; y: number; z: number }
}
}) => void
onError?: (error: string) => void
activeMenu?: string | null
focusSensorId?: string | null
renderOverlay?: (params: { anchor: { left: number; top: number } | null; info?: { name?: string; sensorId?: string } | null }) => React.ReactNode
isSensorSelectionEnabled?: boolean
onSensorPick?: (sensorId: string | null) => void
highlightAllSensors?: boolean
sensorStatusMap?: Record<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, sensorStatusMap)
const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
if (!targetMesh) {
console.warn(`[ModelViewer] Mesh not found for sensor: ${sensorId}`)
return
}
const scene = sceneRef.current
const camera = scene?.activeCamera as ArcRotateCamera
if (!scene || !camera) return
// Calculate bounding box of the sensor mesh
const bbox = (typeof targetMesh.getHierarchyBoundingVectors === 'function')
? targetMesh.getHierarchyBoundingVectors()
: {
min: targetMesh.getBoundingInfo().boundingBox.minimumWorld,
max: targetMesh.getBoundingInfo().boundingBox.maximumWorld
}
const center = bbox.min.add(bbox.max).scale(0.5)
const size = bbox.max.subtract(bbox.min)
const maxDimension = Math.max(size.x, size.y, size.z)
// Calculate optimal camera distance
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
// Stop any current animations
scene.stopAnimation(camera)
// Setup easing
const ease = new CubicEase()
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
const frameRate = 60
const durationMs = 600 // 0.6 seconds for smooth animation
const totalFrames = Math.round((durationMs / 1000) * frameRate)
// Animate camera target position
Animation.CreateAndStartAnimation(
'camTarget',
camera,
'target',
frameRate,
totalFrames,
camera.target.clone(),
center.clone(),
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
// Animate camera radius (zoom)
Animation.CreateAndStartAnimation(
'camRadius',
camera,
'radius',
frameRate,
totalFrames,
camera.radius,
targetRadius,
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
// Call callback to display tooltip
onSensorPick?.(sensorId)
console.log('[ModelViewer] Camera animation started for sensor:', sensorId)
}
useEffect(() => {
isDisposedRef.current = false
isInitializedRef.current = false
return () => {
isDisposedRef.current = true
}
}, [])
useEffect(() => {
if (!canvasRef.current || isInitializedRef.current) return
const canvas = canvasRef.current
setWebglError(null)
let hasWebGL = false
try {
const testCanvas = document.createElement('canvas')
const gl =
testCanvas.getContext('webgl2') ||
testCanvas.getContext('webgl') ||
testCanvas.getContext('experimental-webgl')
hasWebGL = !!gl
} catch {
hasWebGL = false
}
if (!hasWebGL) {
const message = 'WebGL не поддерживается в текущем окружении'
setWebglError(message)
onError?.(message)
setIsLoading(false)
setModelReady(false)
return
}
let engine: Engine
try {
// Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU
engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
const message = `WebGL недоступен: ${errorMessage}`
setWebglError(message)
onError?.(message)
setIsLoading(false)
setModelReady(false)
return
}
engineRef.current = engine
engine.runRenderLoop(() => {
if (!isDisposedRef.current && sceneRef.current) {
sceneRef.current.render()
}
})
const scene = new Scene(engine)
sceneRef.current = scene
scene.clearColor = new Color4(0.1, 0.1, 0.15, 1)
// Оптимизация: включаем FXAA (более легковесное сглаживание)
scene.imageProcessingConfiguration.fxaaEnabled = true
const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene)
camera.attachControl(canvas, true)
camera.lowerRadiusLimit = 2
camera.upperRadiusLimit = 200
camera.wheelDeltaPercentage = 0.01
camera.panningSensibility = 50
camera.angularSensibilityX = 1000
camera.angularSensibilityY = 1000
const ambientLight = new HemisphericLight('ambientLight', new Vector3(0, 1, 0), scene)
ambientLight.intensity = 0.4
ambientLight.diffuse = new Color3(0.7, 0.7, 0.8)
ambientLight.specular = new Color3(0.2, 0.2, 0.3)
ambientLight.groundColor = new Color3(0.3, 0.3, 0.4)
const keyLight = new HemisphericLight('keyLight', new Vector3(1, 1, 0), scene)
keyLight.intensity = 0.6
keyLight.diffuse = new Color3(1, 1, 0.9)
keyLight.specular = new Color3(1, 1, 0.9)
const fillLight = new HemisphericLight('fillLight', new Vector3(-1, 0.5, -1), scene)
fillLight.intensity = 0.3
fillLight.diffuse = new Color3(0.8, 0.8, 1)
const hl = new HighlightLayer('highlight-layer', scene, {
mainTextureRatio: 1,
blurTextureSizeRatio: 1,
})
hl.innerGlow = false
hl.outerGlow = true
hl.blurHorizontalSize = 2
hl.blurVerticalSize = 2
highlightLayerRef.current = hl
const handleResize = () => {
if (!isDisposedRef.current) {
engine.resize()
}
}
window.addEventListener('resize', handleResize)
isInitializedRef.current = true
return () => {
isDisposedRef.current = true
isInitializedRef.current = false
window.removeEventListener('resize', handleResize)
highlightLayerRef.current?.dispose()
highlightLayerRef.current = null
if (engineRef.current) {
engineRef.current.dispose()
engineRef.current = null
}
sceneRef.current = null
}
}, [onError])
useEffect(() => {
if (!modelPath || !sceneRef.current || !engineRef.current) return
const scene = sceneRef.current
setIsLoading(true)
setLoadingProgress(0)
setShowModel(false)
setModelReady(false)
const loadModel = async () => {
try {
console.log('[ModelViewer] Starting to load model:', modelPath)
// UI элемент загрузчика (есть эффект замедленности)
const progressInterval = setInterval(() => {
setLoadingProgress(prev => {
if (prev >= 90) {
clearInterval(progressInterval)
return 90
}
return prev + Math.random() * 15
})
}, 100)
// Use the correct ImportMeshAsync signature: (url, scene, onProgress)
const result = await ImportMeshAsync(modelPath, scene, (evt) => {
if (evt.lengthComputable) {
const progress = (evt.loaded / evt.total) * 100
setLoadingProgress(progress)
console.log('[ModelViewer] Loading progress:', progress)
}
})
clearInterval(progressInterval)
if (isDisposedRef.current) {
console.log('[ModelViewer] Component disposed during load')
return
}
console.log('[ModelViewer] Model loaded successfully:', {
meshesCount: result.meshes.length,
particleSystemsCount: result.particleSystems.length,
skeletonsCount: result.skeletons.length,
animationGroupsCount: result.animationGroups.length
})
importedMeshesRef.current = result.meshes
if (result.meshes.length > 0) {
const boundingBox = result.meshes[0].getHierarchyBoundingVectors()
onModelLoaded?.({
meshes: result.meshes,
boundingBox: {
min: { x: boundingBox.min.x, y: boundingBox.min.y, z: boundingBox.min.z },
max: { x: boundingBox.max.x, y: boundingBox.max.y, z: boundingBox.max.z },
},
})
// Автоматическое кадрирование камеры для отображения всей модели
const camera = scene.activeCamera as ArcRotateCamera
if (camera) {
const center = boundingBox.min.add(boundingBox.max).scale(0.5)
const size = boundingBox.max.subtract(boundingBox.min)
const maxDimension = Math.max(size.x, size.y, size.z)
// Устанавливаем оптимальное расстояние камеры
const targetRadius = maxDimension * 2.5 // Множитель для комфортного отступа
// Плавная анимация камеры к центру модели
scene.stopAnimation(camera)
const ease = new CubicEase()
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
const frameRate = 60
const durationMs = 800 // 0.8 секунды
const totalFrames = Math.round((durationMs / 1000) * frameRate)
// Анимация позиции камеры
Animation.CreateAndStartAnimation(
'frameCameraTarget',
camera,
'target',
frameRate,
totalFrames,
camera.target.clone(),
center.clone(),
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
// Анимация зума
Animation.CreateAndStartAnimation(
'frameCameraRadius',
camera,
'radius',
frameRate,
totalFrames,
camera.radius,
targetRadius,
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
console.log('[ModelViewer] Camera framed to model:', { center, targetRadius, maxDimension })
}
}
setLoadingProgress(100)
setShowModel(true)
setModelReady(true)
setIsLoading(false)
} catch (error) {
if (isDisposedRef.current) return
const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка'
console.error('[ModelViewer] Error loading model:', errorMessage)
const message = `Ошибка при загрузке модели: ${errorMessage}`
onError?.(message)
setIsLoading(false)
setModelReady(false)
}
}
loadModel()
}, [modelPath, onModelLoaded, onError])
useEffect(() => {
if (!highlightAllSensors || focusSensorId || !modelReady) {
setAllSensorsOverlayCircles([])
return
}
const scene = sceneRef.current
const engine = engineRef.current
if (!scene || !engine) {
setAllSensorsOverlayCircles([])
return
}
const allMeshes = importedMeshesRef.current || []
const sensorMeshes = collectSensorMeshes(allMeshes, sensorStatusMap)
if (sensorMeshes.length === 0) {
setAllSensorsOverlayCircles([])
return
}
const engineTyped = engine as Engine
const updateCircles = () => {
const circles = computeSensorOverlayCircles({
scene,
engine: engineTyped,
meshes: sensorMeshes,
sensorStatusMap,
})
setAllSensorsOverlayCircles(circles)
}
updateCircles()
const observer = scene.onBeforeRenderObservable.add(updateCircles)
return () => {
scene.onBeforeRenderObservable.remove(observer)
setAllSensorsOverlayCircles([])
}
}, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap])
useEffect(() => {
if (!highlightAllSensors || focusSensorId || !modelReady) {
return
}
const scene = sceneRef.current
if (!scene) return
const allMeshes = importedMeshesRef.current || []
if (allMeshes.length === 0) {
return
}
// Сначала найдём ВСЕ датчики в 3D модели (без фильтра)
const allSensorMeshesInModel = collectSensorMeshes(allMeshes, null)
const allSensorIdsInModel = allSensorMeshesInModel.map(m => getSensorIdFromMesh(m)).filter(Boolean)
// Теперь применим фильтр по sensorStatusMap
const sensorMeshes = collectSensorMeshes(allMeshes, sensorStatusMap)
const filteredSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean)
console.log('[ModelViewer] Total meshes in model:', allMeshes.length)
console.log('[ModelViewer] ALL sensor meshes in 3D model (unfiltered):', allSensorIdsInModel.length, allSensorIdsInModel)
console.log('[ModelViewer] sensorStatusMap keys count:', sensorStatusMap ? Object.keys(sensorStatusMap).length : 0)
console.log('[ModelViewer] Sensor meshes found (filtered by sensorStatusMap):', sensorMeshes.length, filteredSensorIds)
// Найдём датчики которые есть в sensorStatusMap но НЕТ в 3D модели
if (sensorStatusMap) {
const missingInModel = Object.keys(sensorStatusMap).filter(id => !allSensorIdsInModel.includes(id))
if (missingInModel.length > 0) {
console.warn('[ModelViewer] Sensors in sensorStatusMap but MISSING in 3D model:', missingInModel.length, missingInModel.slice(0, 10))
}
}
if (sensorMeshes.length === 0) {
console.warn('[ModelViewer] No sensor meshes found in 3D model!')
return
}
applyHighlightToMeshes(
highlightLayerRef.current,
highlightedMeshesRef,
sensorMeshes,
mesh => {
const sid = getSensorIdFromMesh(mesh)
const status = sid ? sensorStatusMap?.[sid] : undefined
return statusToColor3(status ?? null)
},
)
}, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap])
useEffect(() => {
if (!focusSensorId || !modelReady) {
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
setAllSensorsOverlayCircles([])
return
}
const sensorId = (focusSensorId ?? '').trim()
if (!sensorId) {
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
return
}
const allMeshes = importedMeshesRef.current || []
if (allMeshes.length === 0) {
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
return
}
const sensorMeshes = collectSensorMeshes(allMeshes, sensorStatusMap)
const allSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m))
const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
console.log('[ModelViewer] Sensor focus', {
requested: sensorId,
totalImportedMeshes: allMeshes.length,
totalSensorMeshes: sensorMeshes.length,
allSensorIds: allSensorIds,
chosen: chosen ? { id: chosen.id, name: chosen.name, uniqueId: chosen.uniqueId, parent: chosen.parent?.name } : null,
source: 'result.meshes',
})
const scene = sceneRef.current!
if (chosen) {
try {
const camera = scene.activeCamera as ArcRotateCamera
const bbox = (typeof chosen.getHierarchyBoundingVectors === 'function')
? chosen.getHierarchyBoundingVectors()
: { min: chosen.getBoundingInfo().boundingBox.minimumWorld, max: chosen.getBoundingInfo().boundingBox.maximumWorld }
const center = bbox.min.add(bbox.max).scale(0.5)
const size = bbox.max.subtract(bbox.min)
const maxDimension = Math.max(size.x, size.y, size.z)
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
// Простое позиционирование камеры - всегда поворачиваемся к датчику
console.log('[ModelViewer] Calculating camera direction to sensor')
// Вычисляем направление от текущей позиции камеры к датчику
const directionToSensor = center.subtract(camera.position).normalize()
// Преобразуем в сферические координаты
// alpha - горизонтальный угол (вокруг оси Y)
let targetAlpha = Math.atan2(directionToSensor.x, directionToSensor.z)
// beta - вертикальный угол (от вертикали)
// Используем оптимальный угол 60° для обзора
let targetBeta = Math.PI / 3 // 60°
console.log('[ModelViewer] Calculated camera direction:', {
alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°',
beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°',
sensorPosition: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) },
cameraPosition: { x: camera.position.x.toFixed(2), y: camera.position.y.toFixed(2), z: camera.position.z.toFixed(2) }
})
// Нормализуем alpha в диапазон [-PI, PI]
while (targetAlpha > Math.PI) targetAlpha -= 2 * Math.PI
while (targetAlpha < -Math.PI) targetAlpha += 2 * Math.PI
// Ограничиваем beta в разумных пределах
targetBeta = Math.max(0.1, Math.min(Math.PI - 0.1, targetBeta))
scene.stopAnimation(camera)
// Логирование перед анимацией
console.log('[ModelViewer] Starting camera animation:', {
sensorId,
from: {
target: { x: camera.target.x.toFixed(2), y: camera.target.y.toFixed(2), z: camera.target.z.toFixed(2) },
radius: camera.radius.toFixed(2),
alpha: (camera.alpha * 180 / Math.PI).toFixed(1) + '°',
beta: (camera.beta * 180 / Math.PI).toFixed(1) + '°'
},
to: {
target: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) },
radius: targetRadius.toFixed(2),
alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°',
beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°'
},
alphaChange: ((targetAlpha - camera.alpha) * 180 / Math.PI).toFixed(1) + '°',
betaChange: ((targetBeta - camera.beta) * 180 / Math.PI).toFixed(1) + '°'
})
const ease = new CubicEase()
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
const frameRate = 60
const durationMs = 800
const totalFrames = Math.round((durationMs / 1000) * frameRate)
Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
Animation.CreateAndStartAnimation('camAlpha', camera, 'alpha', frameRate, totalFrames, camera.alpha, targetAlpha, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
Animation.CreateAndStartAnimation('camBeta', camera, 'beta', frameRate, totalFrames, camera.beta, targetBeta, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
applyHighlightToMeshes(
highlightLayerRef.current,
highlightedMeshesRef,
[chosen],
mesh => {
const sid = getSensorIdFromMesh(mesh)
const status = sid ? sensorStatusMap?.[sid] : undefined
return statusToColor3(status ?? null)
},
)
chosenMeshRef.current = chosen
setOverlayData({ name: chosen.name, sensorId })
} catch {
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
}
} else {
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusSensorId, modelReady, highlightAllSensors])
useEffect(() => {
const scene = sceneRef.current
if (!scene || !modelReady || !isSensorSelectionEnabled) return
const pickObserver = scene.onPointerObservable.add((pointerInfo: PointerInfo) => {
if (pointerInfo.type !== PointerEventTypes.POINTERPICK) return
const pick = pointerInfo.pickInfo
if (!pick || !pick.hit) {
onSensorPick?.(null)
return
}
const pickedMesh = pick.pickedMesh
const sensorId = getSensorIdFromMesh(pickedMesh)
if (sensorId) {
onSensorPick?.(sensorId)
} else {
onSensorPick?.(null)
}
})
return () => {
scene.onPointerObservable.remove(pickObserver)
}
}, [modelReady, isSensorSelectionEnabled, onSensorPick])
const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => {
if (!sceneRef.current || !mesh) return null
const scene = sceneRef.current
try {
const bbox = (typeof mesh.getHierarchyBoundingVectors === 'function')
? mesh.getHierarchyBoundingVectors()
: { min: mesh.getBoundingInfo().boundingBox.minimumWorld, max: mesh.getBoundingInfo().boundingBox.maximumWorld }
const center = bbox.min.add(bbox.max).scale(0.5)
const viewport = scene.activeCamera?.viewport.toGlobal(engineRef.current!.getRenderWidth(), engineRef.current!.getRenderHeight())
if (!viewport) return null
const projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport)
if (!projected) return null
return { left: projected.x, top: projected.y }
} catch (error) {
console.error('[ModelViewer] Error computing overlay position:', error)
return null
}
}, [])
useEffect(() => {
if (!chosenMeshRef.current || !overlayData) return
const pos = computeOverlayPosition(chosenMeshRef.current)
setOverlayPos(pos)
}, [overlayData, computeOverlayPosition])
useEffect(() => {
if (!sceneRef.current || !chosenMeshRef.current || !overlayData) return
const scene = sceneRef.current
const updateOverlayPosition = () => {
const pos = computeOverlayPosition(chosenMeshRef.current)
setOverlayPos(pos)
}
scene.registerBeforeRender(updateOverlayPosition)
return () => scene.unregisterBeforeRender(updateOverlayPosition)
}, [overlayData, computeOverlayPosition])
return (
<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 React.memo(ModelViewer)

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"></polygon>
<line x1="9" y1="3" x2="9" y2="18"></line>
<line x1="15" y1="6" x2="15" y2="21"></line>
</svg>

After

Width:  |  Height:  |  Size: 347 B