diff --git a/frontend/app/(protected)/navigation/page.tsx b/frontend/app/(protected)/navigation/page.tsx index b71a2a5..8941094 100644 --- a/frontend/app/(protected)/navigation/page.tsx +++ b/frontend/app/(protected)/navigation/page.tsx @@ -1,7 +1,8 @@ 'use client' import React, { useEffect, useCallback, useState } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' +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' @@ -546,15 +547,16 @@ const NavigationPage: React.FC = () => { ) : !selectedModelPath ? (
-
-
- 3D модель не загружена -
-
- Модель не готова к отображению -
-
- Выберите модель из навигации по этажам +
+ AerBIM HT Monitor +
+ Выберите модель для отображения
diff --git a/frontend/components/model/ModelViewer.tsx b/frontend/components/model/ModelViewer.tsx index dc2b4db..2ee6469 100644 --- a/frontend/components/model/ModelViewer.tsx +++ b/frontend/components/model/ModelViewer.tsx @@ -480,6 +480,55 @@ const ModelViewer: React.FC = ({ 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) diff --git a/frontend/components/navigation/Monitoring.tsx b/frontend/components/navigation/Monitoring.tsx index 0c60837..19a68da 100644 --- a/frontend/components/navigation/Monitoring.tsx +++ b/frontend/components/navigation/Monitoring.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback, useState } from 'react'; import Image from 'next/image'; import useNavigationStore from '@/app/store/navigationStore'; import type { Zone } from '@/app/types'; @@ -38,7 +38,8 @@ const Monitoring: React.FC = ({ onClose, onSelectModel }) => { const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore(); const handleSelectModel = useCallback((modelPath: string) => { - console.log(`[NavigationPage] Model selected: ${modelPath}`); + console.log(`[Monitoring] Model selected: ${modelPath}`); + console.log(`[Monitoring] onSelectModel callback:`, onSelectModel); onSelectModel?.(modelPath); }, [onSelectModel]); @@ -46,30 +47,21 @@ const Monitoring: React.FC = ({ onClose, onSelectModel }) => { useEffect(() => { const objId = currentObject?.id; if (!objId) return; + console.log(`[Monitoring] Loading zones for object ID: ${objId}`); loadZones(objId); }, [currentObject?.id, loadZones]); - // Автоматический выбор первой зоны при загрузке - useEffect(() => { - const sortedZones: Zone[] = (currentZones || []).slice().sort((a: Zone, b: Zone) => { - const oa = typeof a.order === 'number' ? a.order : 0; - const ob = typeof b.order === 'number' ? b.order : 0; - if (oa !== ob) return oa - ob; - return (a.name || '').localeCompare(b.name || ''); - }); - - if (sortedZones.length > 0 && sortedZones[0].model_path && !zonesLoading) { - handleSelectModel(sortedZones[0].model_path); - } - }, [currentZones, zonesLoading, handleSelectModel]); + // Автоматический выбор первой зоны ОТКЛЮЧЕН - пользователь должен выбрать модель вручную const sortedZones: Zone[] = React.useMemo(() => { - return (currentZones || []).slice().sort((a: Zone, b: Zone) => { + const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => { const oa = typeof a.order === 'number' ? a.order : 0; const ob = typeof b.order === 'number' ? b.order : 0; if (oa !== ob) return oa - ob; return (a.name || '').localeCompare(b.name || ''); }); + console.log(`[Monitoring] Sorted zones:`, sorted.map(z => ({ id: z.id, name: z.name, model_path: z.model_path }))); + return sorted; }, [currentZones]); return ( @@ -106,25 +98,29 @@ const Monitoring: React.FC = ({ onClose, onSelectModel }) => { key={`zone-${sortedZones[0].id}-panorama`} type="button" onClick={() => sortedZones[0].model_path ? handleSelectModel(sortedZones[0].model_path) : null} - className="w-full bg-gray-300 rounded-lg h-[200px] flex items-center justify-center hover:bg-gray-400 transition-colors mb-4" + className="group w-full bg-gradient-to-br from-cyan-600/20 via-blue-600/20 to-purple-600/20 rounded-xl h-[220px] flex items-center justify-center hover:from-cyan-500/30 hover:via-blue-500/30 hover:to-purple-500/30 transition-all duration-300 mb-4 border border-cyan-400/30 hover:border-cyan-400/60 shadow-lg shadow-cyan-500/10 hover:shadow-cyan-500/30 overflow-hidden relative backdrop-blur-sm" title={sortedZones[0].model_path ? `Открыть 3D модель зоны: ${sortedZones[0].name}` : 'Модель зоны отсутствует'} disabled={!sortedZones[0].model_path} > -
- {/* Всегда рендерим с разрешённой заглушкой */} + {/* Градиентный фон при наведении */} +
+ {/* Анимированный градиент по краям */} +
+ +
{sortedZones[0].name { const target = e.target as HTMLImageElement; target.src = '/images/test_image.png'; }} /> -
+
{sortedZones[0].name}
@@ -137,24 +133,29 @@ const Monitoring: React.FC = ({ onClose, onSelectModel }) => { key={`zone-${zone.id}-${idx}`} type="button" onClick={() => zone.model_path ? handleSelectModel(zone.model_path) : null} - className="relative flex-1 bg-gray-300 rounded-lg h-[120px] flex items-center justify-center hover:bg-gray-400 transition-colors" + className="group relative flex-1 bg-gradient-to-br from-emerald-600/20 via-teal-600/20 to-cyan-600/20 rounded-xl h-[140px] flex items-center justify-center hover:from-emerald-500/30 hover:via-teal-500/30 hover:to-cyan-500/30 transition-all duration-300 border border-emerald-400/30 hover:border-emerald-400/60 shadow-lg shadow-emerald-500/10 hover:shadow-emerald-500/30 overflow-hidden backdrop-blur-sm" title={zone.model_path ? `Открыть 3D модель зоны: ${zone.name}` : 'Модель зоны отсутствует'} disabled={!zone.model_path} > -
+ {/* Градиентный фон при наведении */} +
+ {/* Анимированный градиент по краям */} +
+ +
{zone.name { const target = e.target as HTMLImageElement; target.src = '/images/test_image.png'; }} /> -
+
{zone.name}
diff --git a/frontend/components/navigation/Monitoring.tsx — копия b/frontend/components/navigation/Monitoring.tsx — копия new file mode 100644 index 0000000..0c60837 --- /dev/null +++ b/frontend/components/navigation/Monitoring.tsx — копия @@ -0,0 +1,179 @@ +import React, { useEffect, useCallback } from 'react'; +import Image from 'next/image'; +import useNavigationStore from '@/app/store/navigationStore'; +import type { Zone } from '@/app/types'; + +// Безопасный резолвер src изображения, чтобы избежать ошибок Invalid URL в next/image +const resolveImageSrc = (src?: string | null): string => { + if (!src || typeof src !== 'string') return '/images/test_image.png'; + let s = src.trim(); + if (!s) return '/images/test_image.png'; + s = s.replace(/\\/g, '/'); + const lower = s.toLowerCase(); + // Явный плейсхолдер test_image.png маппим на наш статический ресурс + if (lower === 'test_image.png' || lower.endsWith('/test_image.png') || lower.includes('/public/images/test_image.png')) { + return '/images/test_image.png'; + } + // Если путь содержит public/images (даже абсолютный путь ФС), переводим в относительный путь сайта + if (/\/public\/images\//i.test(s)) { + const parts = s.split(/\/public\/images\//i); + const rel = parts[1] || ''; + return `/images/${rel}`; + } + // Абсолютные URL и пути, относительные к сайту + if (s.startsWith('http://') || s.startsWith('https://')) return s; + if (s.startsWith('/')) return s; + // Нормализуем относительные имена ресурсов до путей сайта под /images + // Убираем ведущий 'public/', если он присутствует + s = s.replace(/^public\//i, ''); + return s.startsWith('images/') ? `/${s}` : `/images/${s}`; +} + +interface MonitoringProps { + onClose?: () => void; + onSelectModel?: (modelPath: string) => void; +} + +const Monitoring: React.FC = ({ onClose, onSelectModel }) => { + const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore(); + + const handleSelectModel = useCallback((modelPath: string) => { + console.log(`[NavigationPage] Model selected: ${modelPath}`); + onSelectModel?.(modelPath); + }, [onSelectModel]); + + // Загрузка зон при изменении объекта + useEffect(() => { + const objId = currentObject?.id; + if (!objId) return; + loadZones(objId); + }, [currentObject?.id, loadZones]); + + // Автоматический выбор первой зоны при загрузке + useEffect(() => { + const sortedZones: Zone[] = (currentZones || []).slice().sort((a: Zone, b: Zone) => { + const oa = typeof a.order === 'number' ? a.order : 0; + const ob = typeof b.order === 'number' ? b.order : 0; + if (oa !== ob) return oa - ob; + return (a.name || '').localeCompare(b.name || ''); + }); + + if (sortedZones.length > 0 && sortedZones[0].model_path && !zonesLoading) { + handleSelectModel(sortedZones[0].model_path); + } + }, [currentZones, zonesLoading, handleSelectModel]); + + const sortedZones: Zone[] = React.useMemo(() => { + return (currentZones || []).slice().sort((a: Zone, b: Zone) => { + const oa = typeof a.order === 'number' ? a.order : 0; + const ob = typeof b.order === 'number' ? b.order : 0; + if (oa !== ob) return oa - ob; + return (a.name || '').localeCompare(b.name || ''); + }); + }, [currentZones]); + + return ( +
+
+
+

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

+ {onClose && ( + + )} +
+ {/* UI зон */} + {zonesError && ( +
+ Ошибка загрузки зон: {zonesError} +
+ )} + {zonesLoading && ( +
+ Загрузка зон... +
+ )} + {sortedZones.length > 0 && ( + <> + {sortedZones[0] && ( + + )} + {sortedZones.length > 1 && ( +
+ {sortedZones.slice(1).map((zone: Zone, idx: number) => ( + + ))} +
+ )} + + )} + {sortedZones.length === 0 && !zonesError && !zonesLoading && ( +
+
+ Зоны не найдены для выбранного объекта. Проверьте параметр objectId в API /api/get-zones. +
+
+ )} +
+
+ ); +}; + +export default Monitoring; \ No newline at end of file diff --git a/frontend/components/navigation/Monitoring.tsx — копия 2 b/frontend/components/navigation/Monitoring.tsx — копия 2 new file mode 100644 index 0000000..c21fdbb --- /dev/null +++ b/frontend/components/navigation/Monitoring.tsx — копия 2 @@ -0,0 +1,193 @@ +import React, { useEffect, useCallback, useState } from 'react'; +import Image from 'next/image'; +import useNavigationStore from '@/app/store/navigationStore'; +import type { Zone } from '@/app/types'; + +// Безопасный резолвер src изображения, чтобы избежать ошибок Invalid URL в next/image +const resolveImageSrc = (src?: string | null): string => { + if (!src || typeof src !== 'string') return '/images/test_image.png'; + let s = src.trim(); + if (!s) return '/images/test_image.png'; + s = s.replace(/\\/g, '/'); + const lower = s.toLowerCase(); + // Явный плейсхолдер test_image.png маппим на наш статический ресурс + if (lower === 'test_image.png' || lower.endsWith('/test_image.png') || lower.includes('/public/images/test_image.png')) { + return '/images/test_image.png'; + } + // Если путь содержит public/images (даже абсолютный путь ФС), переводим в относительный путь сайта + if (/\/public\/images\//i.test(s)) { + const parts = s.split(/\/public\/images\//i); + const rel = parts[1] || ''; + return `/images/${rel}`; + } + // Абсолютные URL и пути, относительные к сайту + if (s.startsWith('http://') || s.startsWith('https://')) return s; + if (s.startsWith('/')) return s; + // Нормализуем относительные имена ресурсов до путей сайта под /images + // Убираем ведущий 'public/', если он присутствует + s = s.replace(/^public\//i, ''); + return s.startsWith('images/') ? `/${s}` : `/images/${s}`; +} + +interface MonitoringProps { + onClose?: () => void; + onSelectModel?: (modelPath: string) => void; +} + +const Monitoring: React.FC = ({ onClose, onSelectModel }) => { + const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore(); + const [userSelectedModel, setUserSelectedModel] = useState(false); + + const handleSelectModel = useCallback((modelPath: string, isUserClick = false) => { + console.log(`[Monitoring] Model selected: ${modelPath}, isUserClick: ${isUserClick}`); + console.log(`[Monitoring] onSelectModel callback:`, onSelectModel); + if (isUserClick) { + setUserSelectedModel(true); + } + onSelectModel?.(modelPath); + }, [onSelectModel]); + + // Загрузка зон при изменении объекта + useEffect(() => { + const objId = currentObject?.id; + if (!objId) return; + console.log(`[Monitoring] Loading zones for object ID: ${objId}`); + loadZones(objId); + }, [currentObject?.id, loadZones]); + + // Автоматический выбор первой зоны при загрузке (только если пользователь не выбрал вручную) + useEffect(() => { + if (userSelectedModel) { + console.log('[Monitoring] User already selected model, skipping auto-selection'); + return; + } + + const sortedZones: Zone[] = (currentZones || []).slice().sort((a: Zone, b: Zone) => { + const oa = typeof a.order === 'number' ? a.order : 0; + const ob = typeof b.order === 'number' ? b.order : 0; + if (oa !== ob) return oa - ob; + return (a.name || '').localeCompare(b.name || ''); + }); + + if (sortedZones.length > 0 && sortedZones[0].model_path && !zonesLoading) { + console.log('[Monitoring] Auto-selecting first zone model'); + handleSelectModel(sortedZones[0].model_path, false); + } + }, [currentZones, zonesLoading, handleSelectModel, userSelectedModel]); + + const sortedZones: Zone[] = React.useMemo(() => { + const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => { + const oa = typeof a.order === 'number' ? a.order : 0; + const ob = typeof b.order === 'number' ? b.order : 0; + if (oa !== ob) return oa - ob; + return (a.name || '').localeCompare(b.name || ''); + }); + console.log(`[Monitoring] Sorted zones:`, sorted.map(z => ({ id: z.id, name: z.name, model_path: z.model_path }))); + return sorted; + }, [currentZones]); + + return ( +
+
+
+

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

+ {onClose && ( + + )} +
+ {/* UI зон */} + {zonesError && ( +
+ Ошибка загрузки зон: {zonesError} +
+ )} + {zonesLoading && ( +
+ Загрузка зон... +
+ )} + {sortedZones.length > 0 && ( + <> + {sortedZones[0] && ( + + )} + {sortedZones.length > 1 && ( +
+ {sortedZones.slice(1).map((zone: Zone, idx: number) => ( + + ))} +
+ )} + + )} + {sortedZones.length === 0 && !zonesError && !zonesLoading && ( +
+
+ Зоны не найдены для выбранного объекта. Проверьте параметр objectId в API /api/get-zones. +
+
+ )} +
+
+ ); +}; + +export default Monitoring; \ No newline at end of file diff --git a/frontend/components/navigation/Monitoring.tsx — копия 3 b/frontend/components/navigation/Monitoring.tsx — копия 3 new file mode 100644 index 0000000..7b90fab --- /dev/null +++ b/frontend/components/navigation/Monitoring.tsx — копия 3 @@ -0,0 +1,171 @@ +import React, { useEffect, useCallback, useState } from 'react'; +import Image from 'next/image'; +import useNavigationStore from '@/app/store/navigationStore'; +import type { Zone } from '@/app/types'; + +// Безопасный резолвер src изображения, чтобы избежать ошибок Invalid URL в next/image +const resolveImageSrc = (src?: string | null): string => { + if (!src || typeof src !== 'string') return '/images/test_image.png'; + let s = src.trim(); + if (!s) return '/images/test_image.png'; + s = s.replace(/\\/g, '/'); + const lower = s.toLowerCase(); + // Явный плейсхолдер test_image.png маппим на наш статический ресурс + if (lower === 'test_image.png' || lower.endsWith('/test_image.png') || lower.includes('/public/images/test_image.png')) { + return '/images/test_image.png'; + } + // Если путь содержит public/images (даже абсолютный путь ФС), переводим в относительный путь сайта + if (/\/public\/images\//i.test(s)) { + const parts = s.split(/\/public\/images\//i); + const rel = parts[1] || ''; + return `/images/${rel}`; + } + // Абсолютные URL и пути, относительные к сайту + if (s.startsWith('http://') || s.startsWith('https://')) return s; + if (s.startsWith('/')) return s; + // Нормализуем относительные имена ресурсов до путей сайта под /images + // Убираем ведущий 'public/', если он присутствует + s = s.replace(/^public\//i, ''); + return s.startsWith('images/') ? `/${s}` : `/images/${s}`; +} + +interface MonitoringProps { + onClose?: () => void; + onSelectModel?: (modelPath: string) => void; +} + +const Monitoring: React.FC = ({ onClose, onSelectModel }) => { + const { currentObject, currentZones, zonesLoading, zonesError, loadZones } = useNavigationStore(); + + const handleSelectModel = useCallback((modelPath: string) => { + console.log(`[Monitoring] Model selected: ${modelPath}`); + console.log(`[Monitoring] onSelectModel callback:`, onSelectModel); + onSelectModel?.(modelPath); + }, [onSelectModel]); + + // Загрузка зон при изменении объекта + useEffect(() => { + const objId = currentObject?.id; + if (!objId) return; + console.log(`[Monitoring] Loading zones for object ID: ${objId}`); + loadZones(objId); + }, [currentObject?.id, loadZones]); + + // Автоматический выбор первой зоны ОТКЛЮЧЕН - пользователь должен выбрать модель вручную + + const sortedZones: Zone[] = React.useMemo(() => { + const sorted = (currentZones || []).slice().sort((a: Zone, b: Zone) => { + const oa = typeof a.order === 'number' ? a.order : 0; + const ob = typeof b.order === 'number' ? b.order : 0; + if (oa !== ob) return oa - ob; + return (a.name || '').localeCompare(b.name || ''); + }); + console.log(`[Monitoring] Sorted zones:`, sorted.map(z => ({ id: z.id, name: z.name, model_path: z.model_path }))); + return sorted; + }, [currentZones]); + + return ( +
+
+
+

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

+ {onClose && ( + + )} +
+ {/* UI зон */} + {zonesError && ( +
+ Ошибка загрузки зон: {zonesError} +
+ )} + {zonesLoading && ( +
+ Загрузка зон... +
+ )} + {sortedZones.length > 0 && ( + <> + {sortedZones[0] && ( + + )} + {sortedZones.length > 1 && ( +
+ {sortedZones.slice(1).map((zone: Zone, idx: number) => ( + + ))} +
+ )} + + )} + {sortedZones.length === 0 && !zonesError && !zonesLoading && ( +
+
+ Зоны не найдены для выбранного объекта. Проверьте параметр objectId в API /api/get-zones. +
+
+ )} +
+
+ ); +}; + +export default Monitoring; \ No newline at end of file