From 66e2bab68396cf43fc472f86f49c4a983da8a755 Mon Sep 17 00:00:00 2001 From: iv_vuytsik Date: Mon, 20 Oct 2025 11:02:34 +0300 Subject: [PATCH] Linked backend data to detectors' meshes. --- frontend/app/(protected)/alerts/page.tsx | 26 +++ frontend/app/(protected)/navigation/page.tsx | 45 +++-- frontend/app/api/get-dashboard/route.ts | 73 +++++++ frontend/app/api/get-detectors/route.ts | 1 + frontend/app/api/update-alert/[id]/route.ts | 71 +++++++ frontend/app/store/navigationStore.ts | 1 + frontend/components/dashboard/AreaChart.tsx | 56 ++++-- frontend/components/dashboard/BarChart.tsx | 12 +- frontend/components/dashboard/Dashboard.tsx | 98 ++++------ frontend/components/model/ModelViewer.tsx | 185 +++++++++++++++--- .../components/navigation/DetectorMenu.tsx | 130 +++++++++--- .../components/navigation/FloorNavigation.tsx | 1 + .../notifications/Notifications.tsx | 1 + frontend/components/reports/ReportsList.tsx | 1 + frontend/types/detectors.ts | 1 + 15 files changed, 546 insertions(+), 156 deletions(-) create mode 100644 frontend/app/api/get-dashboard/route.ts create mode 100644 frontend/app/api/update-alert/[id]/route.ts diff --git a/frontend/app/(protected)/alerts/page.tsx b/frontend/app/(protected)/alerts/page.tsx index 7db801b..4709ac1 100644 --- a/frontend/app/(protected)/alerts/page.tsx +++ b/frontend/app/(protected)/alerts/page.tsx @@ -109,6 +109,26 @@ const AlertsPage: React.FC = () => { } } + const handleAcknowledgeToggle = async (alertId: number) => { + try { + const res = await fetch(`/api/update-alert/${alertId}`, { method: 'PATCH' }) + const payload = await res.json().catch(() => null) + console.log('[AlertsPage] PATCH /api/update-alert', { id: alertId, status: res.status, payload }) + if (!res.ok) { + throw new Error(typeof payload?.error === 'string' ? payload.error : `Update failed (${res.status})`) + } + // Обновить алерты + const params = new URLSearchParams() + if (currentObject.id) params.set('objectId', currentObject.id) + const listRes = await fetch(`/api/get-alerts?${params.toString()}`, { cache: 'no-store' }) + const listPayload = await listRes.json().catch(() => null) + const data = Array.isArray(listPayload?.data) ? listPayload.data : (listPayload?.data?.alerts || []) + setAlerts(data as AlertItem[]) + } catch (e) { + console.error('Failed to update alert:', e) + } + } + return (
{ }`}> {item.acknowledged ? 'Да' : 'Нет'} +
{new Date(item.timestamp).toLocaleString('ru-RU')}
diff --git a/frontend/app/(protected)/navigation/page.tsx b/frontend/app/(protected)/navigation/page.tsx index 9097120..6233cf4 100644 --- a/frontend/app/(protected)/navigation/page.tsx +++ b/frontend/app/(protected)/navigation/page.tsx @@ -10,19 +10,21 @@ import DetectorMenu from '../../../components/navigation/DetectorMenu' import Notifications from '../../../components/notifications/Notifications' import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo' import dynamic from 'next/dynamic' +import type { ModelViewerProps } from '../../../components/model/ModelViewer' -const ModelViewer = dynamic(() => import('../../../components/model/ModelViewer'), { - ssr: false, - loading: () => ( -
-
Загрузка 3D-модуля…
-
- ), -}) +const ModelViewer = dynamic(() => import('../../../components/model/ModelViewer'), { + ssr: false, + loading: () => ( +
+
Загрузка 3D-модуля…
+
+ ), + }) interface DetectorType { detector_id: number name: string + serial_number: string object: string status: string checked: boolean @@ -122,6 +124,13 @@ const NavigationPage: React.FC = () => { } const handleDetectorMenuClick = (detector: DetectorType) => { + // Для тестов. Выбор детектора. + console.log('[NavigationPage] Selected detector click:', { + detector_id: detector.detector_id, + name: detector.name, + serial_number: detector.serial_number, + }) + if (selectedDetector?.detector_id === detector.detector_id && showDetectorMenu) { setShowDetectorMenu(false) setSelectedDetector(null) @@ -226,12 +235,7 @@ const NavigationPage: React.FC = () => { })()} {showFloorNavigation && showDetectorMenu && selectedDetector && ( - + null )}
@@ -263,6 +267,19 @@ const NavigationPage: React.FC = () => { modelPath='/static-models/AerBIM_Monitor_ASM_HT_Viewer_Expo2017Astana_Level_+1430_custom_prop.glb' onModelLoaded={handleModelLoaded} onError={handleModelError} + focusSensorId={selectedDetector?.serial_number ?? null} + renderOverlay={({ anchor }) => ( + selectedDetector && showDetectorMenu && anchor ? ( + + ) : null + )} />
diff --git a/frontend/app/api/get-dashboard/route.ts b/frontend/app/api/get-dashboard/route.ts new file mode 100644 index 0000000..69f3e07 --- /dev/null +++ b/frontend/app/api/get-dashboard/route.ts @@ -0,0 +1,73 @@ +import { NextResponse, NextRequest } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { getToken } from 'next-auth/jwt' + +export async function GET(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + const authHeader = req.headers.get('authorization') || req.headers.get('Authorization') + const bearer = authHeader && authHeader.toLowerCase().startsWith('bearer ') ? authHeader.slice(7) : undefined + const secret = process.env.NEXTAUTH_SECRET + const token = await getToken({ req, secret }).catch(() => null) + + let accessToken = session?.accessToken || bearer || (token as any)?.accessToken + const refreshToken = session?.refreshToken || (token as any)?.refreshToken + + if (!accessToken && refreshToken) { + try { + const refreshRes = await fetch(`${process.env.BACKEND_URL}/auth/refresh/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh: refreshToken }), + }) + if (refreshRes.ok) { + const refreshed = await refreshRes.json() + accessToken = refreshed.access + } + } catch {} + } + + if (!accessToken) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const backendUrl = process.env.BACKEND_URL + if (!backendUrl) { + return NextResponse.json({ success: false, error: 'BACKEND_URL is not configured' }, { status: 500 }) + } + + const url = new URL(req.url) + const timePeriod = url.searchParams.get('time_period') + const qs = timePeriod ? `?time_period=${encodeURIComponent(timePeriod)}` : '' + + const res = await fetch(`${backendUrl}/account/get-dashboard/${qs}`, { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + cache: 'no-store', + }) + + const text = await res.text() + let payload: any + try { payload = JSON.parse(text) } catch { payload = text } + + if (!res.ok) { + const err = typeof payload === 'string' ? payload : JSON.stringify(payload) + return NextResponse.json({ success: false, error: `Backend dashboard error: ${err}` }, { status: res.status }) + } + + return NextResponse.json({ success: true, data: payload }) + } catch (error) { + console.error('Error fetching dashboard data:', error) + return NextResponse.json( + { + success: false, + error: 'Failed to fetch dashboard data', + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/frontend/app/api/get-detectors/route.ts b/frontend/app/api/get-detectors/route.ts index 4392acf..3ce0ce0 100644 --- a/frontend/app/api/get-detectors/route.ts +++ b/frontend/app/api/get-detectors/route.ts @@ -66,6 +66,7 @@ export async function GET() { object: objectId, checked: sensor.checked ?? false, location: sensor.zone ?? '', + serial_number: sensor.serial_number ?? sensor.name ?? '', } } diff --git a/frontend/app/api/update-alert/[id]/route.ts b/frontend/app/api/update-alert/[id]/route.ts new file mode 100644 index 0000000..2e75f40 --- /dev/null +++ b/frontend/app/api/update-alert/[id]/route.ts @@ -0,0 +1,71 @@ +import { NextResponse, NextRequest } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { getToken } from 'next-auth/jwt' + +export async function PATCH(req: NextRequest, context: { params: Promise<{ id: string }> }) { + try { + const session = await getServerSession(authOptions) + const authHeader = req.headers.get('authorization') || req.headers.get('Authorization') + const bearer = authHeader && authHeader.toLowerCase().startsWith('bearer ') ? authHeader.slice(7) : undefined + const secret = process.env.NEXTAUTH_SECRET + const token = await getToken({ req, secret }).catch(() => null) + + let accessToken = session?.accessToken || bearer || (token as any)?.accessToken + const refreshToken = session?.refreshToken || (token as any)?.refreshToken + + if (!accessToken && refreshToken) { + try { + const refreshRes = await fetch(`${process.env.BACKEND_URL}/auth/refresh/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh: refreshToken }), + }) + if (refreshRes.ok) { + const refreshed = await refreshRes.json() + accessToken = refreshed.access + } + } catch {} + } + + if (!accessToken) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const backendUrl = process.env.BACKEND_URL + if (!backendUrl) { + return NextResponse.json({ success: false, error: 'BACKEND_URL is not configured' }, { status: 500 }) + } + + const { id } = await context.params + const res = await fetch(`${backendUrl}/account/update-alert/${id}/`, { + method: 'PATCH', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + cache: 'no-store', + }) + + const text = await res.text() + let payload: any + try { payload = JSON.parse(text) } catch { payload = text } + + if (!res.ok) { + const err = typeof payload === 'string' ? payload : JSON.stringify(payload) + return NextResponse.json({ success: false, error: `Backend update-alert error: ${err}` }, { status: res.status }) + } + + return NextResponse.json({ success: true, data: payload }) + } catch (error) { + console.error('Error updating alert status:', error) + return NextResponse.json( + { + success: false, + error: 'Failed to update alert status', + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/frontend/app/store/navigationStore.ts b/frontend/app/store/navigationStore.ts index b48c7be..cfafd20 100644 --- a/frontend/app/store/navigationStore.ts +++ b/frontend/app/store/navigationStore.ts @@ -4,6 +4,7 @@ import { persist } from 'zustand/middleware' export interface DetectorType { detector_id: number name: string + serial_number: string object: string status: string type: string diff --git a/frontend/components/dashboard/AreaChart.tsx b/frontend/components/dashboard/AreaChart.tsx index 61c20bd..f2c76f4 100644 --- a/frontend/components/dashboard/AreaChart.tsx +++ b/frontend/components/dashboard/AreaChart.tsx @@ -13,33 +13,51 @@ interface AreaChartProps { data?: ChartDataPoint[] } -const AreaChart: React.FC = ({ className = '' }) => { +const AreaChart: React.FC = ({ className = '', data }) => { + const width = 635 + const height = 200 + const paddingBottom = 20 + const baselineY = height - paddingBottom + const maxPlotHeight = height - 40 + + const safeData = (Array.isArray(data) && data.length > 0) + ? data + : [ + { value: 5 }, + { value: 3 }, + { value: 7 }, + { value: 2 }, + { value: 6 }, + { value: 4 }, + { value: 8 } + ] + + const maxVal = Math.max(...safeData.map(d => d.value || 0), 1) + const stepX = safeData.length > 1 ? width / (safeData.length - 1) : width + + const points = safeData.map((d, i) => { + const x = i * stepX + const y = baselineY - (Math.min(d.value || 0, maxVal) / maxVal) * maxPlotHeight + return { x, y } + }) + + const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + const areaPath = `${linePath} L${width},${baselineY} L0,${baselineY} Z` + return (
- + - - - - - - - - - + + + {points.map((p, i) => ( + + ))}
) diff --git a/frontend/components/dashboard/BarChart.tsx b/frontend/components/dashboard/BarChart.tsx index 7485ad1..8e90e54 100644 --- a/frontend/components/dashboard/BarChart.tsx +++ b/frontend/components/dashboard/BarChart.tsx @@ -13,8 +13,8 @@ interface BarChartProps { data?: ChartDataPoint[] } -const BarChart: React.FC = ({ className = '' }) => { - const barData = [ +const BarChart: React.FC = ({ className = '', data }) => { + const defaultData = [ { value: 80, color: 'rgb(42, 157, 144)' }, { value: 65, color: 'rgb(42, 157, 144)' }, { value: 90, color: 'rgb(42, 157, 144)' }, @@ -29,6 +29,12 @@ const BarChart: React.FC = ({ className = '' }) => { { value: 80, color: 'rgb(42, 157, 144)' } ] + const barData = (Array.isArray(data) && data.length > 0) + ? data.map(d => ({ value: d.value, color: d.color || 'rgb(42, 157, 144)' })) + : defaultData + + const maxVal = Math.max(...barData.map(b => b.value || 0), 1) + return (
@@ -37,7 +43,7 @@ const BarChart: React.FC = ({ className = '' }) => { const barWidth = 40 const barSpacing = 12 const x = index * (barWidth + barSpacing) + 20 - const barHeight = (bar.value / 100) * 160 + const barHeight = (bar.value / maxVal) * 160 const y = 180 - barHeight return ( diff --git a/frontend/components/dashboard/Dashboard.tsx b/frontend/components/dashboard/Dashboard.tsx index bc1d1f9..637a8d1 100644 --- a/frontend/components/dashboard/Dashboard.tsx +++ b/frontend/components/dashboard/Dashboard.tsx @@ -15,55 +15,39 @@ const Dashboard: React.FC = () => { const objectId = currentObject?.id const objectTitle = currentObject?.title - const [detectorsArray, setDetectorsArray] = useState([]) + const [dashboardAlerts, setDashboardAlerts] = useState([]) + const [chartData, setChartData] = useState<{ timestamp: string; value: number }[]>([]) useEffect(() => { - const loadDetectors = async () => { + const loadDashboard = async () => { try { - const res = await fetch('/api/get-detectors', { cache: 'no-store' }) + const res = await fetch('/api/get-dashboard', { cache: 'no-store' }) if (!res.ok) return const payload = await res.json() - console.log('[Dashboard] GET /api/get-detectors', { status: res.status, payload }) - const detectorsData = payload?.data?.detectors ?? {} - const arr = Object.values(detectorsData).filter( - (detector: any) => (objectId ? detector.object === objectId : true) - ) - setDetectorsArray(arr as any[]) + console.log('[Dashboard] GET /api/get-dashboard', { status: res.status, payload }) + const tableData = payload?.data?.table_data ?? [] + const arr = (Array.isArray(tableData) ? tableData : []) + .filter((a: any) => (objectTitle ? a.object === objectTitle : true)) + setDashboardAlerts(arr as any[]) + + const cd = Array.isArray(payload?.data?.chart_data) ? payload.data.chart_data : [] + setChartData(cd as any[]) } catch (e) { - console.error('Failed to load detectors:', e) + console.error('Failed to load dashboard:', e) } } - loadDetectors() - }, [objectId]) + loadDashboard() + }, [objectTitle]) const handleBackClick = () => { router.push('/objects') } - interface DetectorData { - detector_id: number - name: string - object: string - status: string - type: string - location: string - floor: number - checked?: boolean - notifications: Array<{ - id: number - type: string - message: string - timestamp: string - acknowledged: boolean - priority: string - }> - } - // Статусы - const statusCounts = detectorsArray.reduce((acc: { critical: number; warning: number; normal: number }, detector: DetectorData) => { - if (detector.status === '#b3261e') acc.critical++ - else if (detector.status === '#fd7c22') acc.warning++ - else if (detector.status === '#00ff00') acc.normal++ + const statusCounts = dashboardAlerts.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 }) @@ -139,14 +123,14 @@ const Dashboard: React.FC = () => { title="Показатель" subtitle="За последние 6 месяцев" > - + - + ({ value: d.value }))} />
@@ -174,36 +158,26 @@ const Dashboard: React.FC = () => { Детектор - Статус - Местоположение - Проверен + Сообщение + Серьезность + Дата + Решен - {detectorsArray.map((detector: DetectorData) => ( - - {detector.name} + {dashboardAlerts.map((alert: any) => ( + + {alert.name} + {alert.message} -
-
- - {detector.status === '#b3261e' ? 'Критическое' : - detector.status === '#fd7c22' ? 'Предупреждение' : 'Норма'} - -
+ + {alert.severity === 'critical' ? 'Критическое' : alert.severity === 'warning' ? 'Предупреждение' : 'Норма'} + - {detector.location} + {new Date(alert.created_at).toLocaleString()} - {detector.checked ? ( -
- - - - Да -
+ {alert.resolved ? ( + Да ) : ( Нет )} @@ -217,7 +191,7 @@ const Dashboard: React.FC = () => { {/* Статы */}
-
{detectorsArray.length}
+
{dashboardAlerts.length}
Всего
diff --git a/frontend/components/model/ModelViewer.tsx b/frontend/components/model/ModelViewer.tsx index 85b3c6f..7bc1f3f 100644 --- a/frontend/components/model/ModelViewer.tsx +++ b/frontend/components/model/ModelViewer.tsx @@ -11,14 +11,22 @@ import { Color4, AbstractMesh, Nullable, - ImportMeshAsync + ImportMeshAsync, + HighlightLayer, + Mesh, + InstancedMesh, + Animation, + CubicEase, + EasingFunction, + Matrix, + Viewport } from '@babylonjs/core' import '@babylonjs/loaders' import LoadingSpinner from '../ui/LoadingSpinner' -interface ModelViewerProps { +export interface ModelViewerProps { modelPath: string onModelLoaded?: (modelData: { meshes: AbstractMesh[] @@ -27,13 +35,17 @@ interface ModelViewerProps { max: { x: number; y: number; z: number } } }) => void - onError?: (error: string) => void + onError?: (error: string) => void + focusSensorId?: string | null + renderOverlay?: (params: { anchor: { left: number; top: number } | null; info?: { name?: string; sensorId?: string } | null }) => React.ReactNode } const ModelViewer: React.FC = ({ modelPath, onModelLoaded, - onError + onError, + focusSensorId, + renderOverlay, }) => { const canvasRef = useRef(null) const engineRef = useRef>(null) @@ -43,7 +55,13 @@ const ModelViewer: React.FC = ({ const [showModel, setShowModel] = useState(false) const isInitializedRef = useRef(false) const isDisposedRef = useRef(false) - + const importedMeshesRef = useRef([]) + const highlightLayerRef = useRef(null) + const chosenMeshRef = useRef(null) + const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null) + const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null) + const [modelReady, setModelReady] = useState(false) + useEffect(() => { isDisposedRef.current = false @@ -94,6 +112,9 @@ const ModelViewer: React.FC = ({ 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) + highlightLayerRef.current = hl const handleResize = () => { if (!isDisposedRef.current) { @@ -108,6 +129,9 @@ const ModelViewer: React.FC = ({ 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 @@ -147,37 +171,14 @@ const ModelViewer: React.FC = ({ try { const result = await ImportMeshAsync(modelPath, sceneRef.current) + + importedMeshesRef.current = result.meshes clearInterval(progressInterval) setLoadingProgress(100) console.log('GLTF Model loaded successfully!') - console.log('\n=== IfcSensor Meshes ===') - const sensorMeshes = (result.meshes || []).filter(m => (m.id ?? '').includes('IfcSensor')) - console.log('IfcSensor Mesh count:', sensorMeshes.length) - sensorMeshes.forEach(m => { - const meta: any = (m as any).metadata - const extras = meta?.extras ?? meta?.gltf?.extras - console.group(`IfcSensor Mesh: ${m.id || m.name}`) - console.log('id:', m.id) - console.log('name:', m.name) - console.log('uniqueId:', m.uniqueId) - console.log('class:', typeof (m as any).getClassName === 'function' ? (m as any).getClassName() : 'Mesh') - console.log('material:', m.material?.name) - console.log('parent:', m.parent?.name) - console.log('metadata:', meta) - if (extras) console.log('extras:', extras) - const bi = m.getBoundingInfo?.() - const bb = bi?.boundingBox - if (bb) { - console.log('bounding.center:', bb.center) - console.log('bounding.extendSize:', bb.extendSize) - } - const verts = (m as any).getTotalVertices?.() - if (typeof verts === 'number') console.log('vertices:', verts) - console.groupEnd() - }) - + console.log('[ModelViewer] ImportMeshAsync result:', result) if (result.meshes.length > 0) { const boundingBox = result.meshes[0].getHierarchyBoundingVectors() @@ -187,7 +188,10 @@ const ModelViewer: React.FC = ({ const camera = sceneRef.current!.activeCamera as ArcRotateCamera camera.radius = maxDimension * 2 camera.target = result.meshes[0].position - + + importedMeshesRef.current = result.meshes + setModelReady(true) + onModelLoaded?.({ meshes: result.meshes, boundingBox: { @@ -220,6 +224,113 @@ const ModelViewer: React.FC = ({ requestIdleCallback(() => loadModel(), { timeout: 50 }) }, [modelPath, onError, onModelLoaded]) + useEffect(() => { + if (!sceneRef.current || isDisposedRef.current || !modelReady) return + + const sensorId = (focusSensorId ?? '').trim() + if (!sensorId) { + console.log('[ModelViewer] Focus cleared (no Sensor_ID provided)') + + highlightLayerRef.current?.removeAllMeshes() + chosenMeshRef.current = null + setOverlayPos(null) + setOverlayData(null) + return + } + + const allMeshes = importedMeshesRef.current || [] + const sensorMeshes = allMeshes.filter((m: any) => ((m.id ?? '').includes('IfcSensor') || (m.name ?? '').includes('IfcSensor'))) + + const chosen = sensorMeshes.find((m: any) => { + try { + const meta: any = (m as any)?.metadata + const extras: any = meta?.gltf?.extras ?? meta?.extras ?? (m as any)?.extras + const sid = extras?.Sensor_ID ?? extras?.sensor_id ?? extras?.SERIAL_NUMBER ?? extras?.serial_number + if (sid == null) return false + return String(sid).trim() === sensorId + } catch { + return false + } + }) + + console.log('[ModelViewer] Sensor focus', { + requested: sensorId, + totalImportedMeshes: allMeshes.length, + totalSensorMeshes: sensorMeshes.length, + 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) { + const camera = scene.activeCamera as ArcRotateCamera + const bbox = (typeof chosen.getHierarchyBoundingVectors === 'function') + ? chosen.getHierarchyBoundingVectors() + : { min: chosen.getBoundingInfo().boundingBox.minimumWorld, max: chosen.getBoundingInfo().boundingBox.maximumWorld } + const center = bbox.min.add(bbox.max).scale(0.5) + const size = bbox.max.subtract(bbox.min) + const maxDimension = Math.max(size.x, size.y, size.z) + const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) + + scene.stopAnimation(camera) + + const ease = new CubicEase() + ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) + const frameRate = 60 + const durationMs = 600 + const totalFrames = Math.round((durationMs / 1000) * frameRate) + + Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease) + Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) + + const hl = highlightLayerRef.current + if (hl) { + hl.removeAllMeshes() + if (chosen instanceof Mesh) { + hl.addMesh(chosen, new Color3(1, 1, 0)) + } else if (chosen instanceof InstancedMesh) { + hl.addMesh(chosen.sourceMesh, new Color3(1, 1, 0)) + } else { + const children = typeof (chosen as any)?.getChildMeshes === 'function' ? (chosen as any).getChildMeshes() : [] + for (const cm of children) { + if (cm instanceof Mesh) { + hl.addMesh(cm, new Color3(1, 1, 0)) + } + } + } + } + chosenMeshRef.current = chosen + setOverlayData({ name: chosen.name, sensorId }) + } else { + highlightLayerRef.current?.removeAllMeshes() + chosenMeshRef.current = null + setOverlayPos(null) + setOverlayData(null) + } + }, [focusSensorId, modelReady]) + + useEffect(() => { + const scene = sceneRef.current + if (!scene || isDisposedRef.current) return + const observer = scene.onAfterRenderObservable.add(() => { + const chosen = chosenMeshRef.current + if (!chosen) return + const engine = scene.getEngine() + const cam = scene.activeCamera + if (!cam) return + const center = chosen.getBoundingInfo().boundingBox.centerWorld + const world = Matrix.IdentityReadOnly + const transform = scene.getTransformMatrix() + const viewport = new Viewport(0, 0, engine.getRenderWidth(), engine.getRenderHeight()) + const projected = Vector3.Project(center, world, transform, viewport) + setOverlayPos({ left: projected.x, top: projected.y }) + }) + return () => { + scene.onAfterRenderObservable.remove(observer) + } + }, []) + return (
= ({ />
)} + {renderOverlay + ? renderOverlay({ anchor: overlayPos, info: overlayData }) + : (overlayData && overlayPos && ( +
+
+
{overlayData.name || 'Sensor'}
+ {overlayData.sensorId &&
ID: {overlayData.sensorId}
} +
+
+ ))}
) } diff --git a/frontend/components/navigation/DetectorMenu.tsx b/frontend/components/navigation/DetectorMenu.tsx index 9bcffa2..dfecef6 100644 --- a/frontend/components/navigation/DetectorMenu.tsx +++ b/frontend/components/navigation/DetectorMenu.tsx @@ -5,6 +5,7 @@ import React from 'react' interface DetectorType { detector_id: number name: string + serial_number: string object: string status: string checked: boolean @@ -18,36 +19,50 @@ interface DetectorMenuProps { isOpen: boolean onClose: () => void getStatusText: (status: string) => string + compact?: boolean + anchor?: { left: number; top: number } | null } -const DetectorMenu: React.FC = ({ detector, isOpen, onClose, getStatusText }) => { +const DetectorMenu: React.FC = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => { if (!isOpen) return null - return ( -
-
-
-

- Датч.{detector.name} -

-
- - + const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => ( +
+ {compact ? ( + <> +
+
+
Маркировка по проекту
+
{detector.name}
+
+
+
Тип детектора
+
{detector.type}
+
-
- - {/* Таблица детекторов */} -
+
+
+
Местоположение
+
{detector.location}
+
+
+
Статус
+
{getStatusText(detector.status)}
+
+
+
+
+
Временная метка
+
Сегодня, 14:30
+
+
+
Этаж
+
{detector.floor}
+
+
+ + ) : ( + <>
Маркировка по проекту
@@ -77,8 +92,71 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose,
Вчера
+ + )} +
+ ) + + if (compact && anchor) { + return ( +
+
+
+
+
Датч.{detector.name}
+
{getStatusText(detector.status)}
+
+ +
+
+ + +
+
- +
+ ) + } + + return ( +
+
+
+

+ Датч.{detector.name} +

+
+ + +
+
+ + +