From 87a1a628d398e9e5faf64e46eff38ffebbea7682 Mon Sep 17 00:00:00 2001 From: iv_vuytsik Date: Wed, 21 Jan 2026 03:16:52 +0300 Subject: [PATCH] Overhaul of the highlight system --- frontend/app/(protected)/navigation/page.tsx | 23 +- frontend/app/api/get-detectors/route.ts | 11 +- frontend/components/alerts/AlertsList.tsx | 24 +- frontend/components/alerts/DetectorList.tsx | 26 +- frontend/components/model/ModelViewer.tsx | 347 +++++++++--------- frontend/components/model/sensorHighlight.ts | 96 +++++ .../model/sensorHighlightOverlay.ts | 72 ++++ frontend/components/navigation/AlertMenu.tsx | 27 +- .../components/navigation/FloorNavigation.tsx | 37 +- .../components/navigation/ListOfDetectors.tsx | 27 +- .../NotificationDetectorInfo.tsx | 19 +- frontend/components/reports/ReportsList.tsx | 27 +- frontend/lib/statusColors.ts | 4 + 13 files changed, 481 insertions(+), 259 deletions(-) create mode 100644 frontend/components/model/sensorHighlight.ts create mode 100644 frontend/components/model/sensorHighlightOverlay.ts create mode 100644 frontend/lib/statusColors.ts diff --git a/frontend/app/(protected)/navigation/page.tsx b/frontend/app/(protected)/navigation/page.tsx index 73053db..23e699d 100644 --- a/frontend/app/(protected)/navigation/page.tsx +++ b/frontend/app/(protected)/navigation/page.tsx @@ -1,5 +1,5 @@ 'use client' - + import React, { useEffect, useCallback, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import Sidebar from '../../../components/ui/Sidebar' @@ -14,7 +14,8 @@ 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(() => import('../../../components/model/ModelViewer'), { ssr: false, loading: () => ( @@ -109,6 +110,15 @@ const NavigationPage: React.FC = () => { const [isModelReady, setIsModelReady] = useState(false) const [focusedSensorId, setFocusedSensorId] = useState(null) const [highlightAllSensors, setHighlightAllSensors] = useState(false) + const sensorStatusMap = React.useMemo(() => { + const map: Record = {} + Object.values(detectorsData.detectors).forEach(d => { + if (d.serial_number && d.status) { + map[String(d.serial_number).trim()] = d.status + } + }) + return map + }, [detectorsData]) useEffect(() => { if (selectedDetector === null && selectedAlert === null) { @@ -353,13 +363,13 @@ const NavigationPage: React.FC = () => { const getStatusText = (status: string) => { const s = (status || '').toLowerCase() switch (s) { - case '#b3261e': + case statusColors.STATUS_COLOR_CRITICAL: case 'critical': return 'Критический' - case '#fd7c22': + case statusColors.STATUS_COLOR_WARNING: case 'warning': return 'Предупреждение' - case '#00ff00': + case statusColors.STATUS_COLOR_NORMAL: case 'normal': return 'Норма' default: @@ -546,6 +556,7 @@ const NavigationPage: React.FC = () => { activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null} focusSensorId={focusedSensorId} highlightAllSensors={highlightAllSensors} + sensorStatusMap={sensorStatusMap} isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors} onSensorPick={handleSensorSelection} renderOverlay={({ anchor }) => ( @@ -580,4 +591,4 @@ const NavigationPage: React.FC = () => { ) } -export default NavigationPage \ No newline at end of file +export default NavigationPage diff --git a/frontend/app/api/get-detectors/route.ts b/frontend/app/api/get-detectors/route.ts index f50ac4c..8e3effd 100644 --- a/frontend/app/api/get-detectors/route.ts +++ b/frontend/app/api/get-detectors/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' +import * as statusColors from '@/lib/statusColors' export async function GET() { try { @@ -50,15 +51,15 @@ export async function GET() { } const statusToColor: Record = { - critical: '#b3261e', - warning: '#fd7c22', - normal: '#00ff00', + critical: statusColors.STATUS_COLOR_CRITICAL, + warning: statusColors.STATUS_COLOR_WARNING, + normal: statusColors.STATUS_COLOR_NORMAL, } const transformedDetectors: Record = {} const detectorsObj = detectorsPayload?.detectors ?? {} for (const [key, sensor] of Object.entries(detectorsObj)) { - const color = statusToColor[sensor.status] ?? '#00ff00' + const color = statusToColor[sensor.status] ?? statusColors.STATUS_COLOR_NORMAL const objectId = titleToIdMap[sensor.object] || sensor.object transformedDetectors[key] = { ...sensor, @@ -100,4 +101,4 @@ export async function GET() { { status: 500 } ) } -} \ No newline at end of file +} diff --git a/frontend/components/alerts/AlertsList.tsx b/frontend/components/alerts/AlertsList.tsx index 9b2d850..7704040 100644 --- a/frontend/components/alerts/AlertsList.tsx +++ b/frontend/components/alerts/AlertsList.tsx @@ -1,4 +1,5 @@ import React, { useState, useMemo } from 'react' +import * as statusColors from '../../lib/statusColors' interface AlertItem { id: number @@ -31,10 +32,14 @@ const AlertsList: React.FC = ({ alerts, onAcknowledgeToggle, in const getStatusColor = (type: string) => { switch (type) { - case 'critical': return '#b3261e' - case 'warning': return '#fd7c22' - case 'info': return '#00ff00' - default: return '#666' + case 'critical': + return statusColors.STATUS_COLOR_CRITICAL + case 'warning': + return statusColors.STATUS_COLOR_WARNING + case 'info': + return statusColors.STATUS_COLOR_NORMAL + default: + return statusColors.STATUS_COLOR_UNKNOWN } } @@ -104,7 +109,14 @@ const AlertsList: React.FC = ({ alerts, onAcknowledgeToggle, in {item.priority === 'high' ? 'Высокий' : item.priority === 'medium' ? 'Средний' : 'Низкий'} @@ -142,4 +154,4 @@ const AlertsList: React.FC = ({ alerts, onAcknowledgeToggle, in ) } -export default AlertsList \ No newline at end of file +export default AlertsList diff --git a/frontend/components/alerts/DetectorList.tsx b/frontend/components/alerts/DetectorList.tsx index c744e10..02a2cad 100644 --- a/frontend/components/alerts/DetectorList.tsx +++ b/frontend/components/alerts/DetectorList.tsx @@ -1,7 +1,8 @@ 'use client' - + import React, { useState, useEffect } from 'react' - +import * as statusColors from '../../lib/statusColors' + interface Detector { detector_id: number name: string @@ -30,14 +31,14 @@ interface RawDetector { priority: string }> } - + interface DetectorListProps { objectId?: string selectedDetectors: number[] onDetectorSelect: (detectorId: number, selected: boolean) => void initialSearchTerm?: string } - + const DetectorList: React.FC = ({ objectId, selectedDetectors, onDetectorSelect, initialSearchTerm = '' }) => { const [detectors, setDetectors] = useState([]) const [searchTerm, setSearchTerm] = useState(initialSearchTerm) @@ -150,12 +151,15 @@ const DetectorList: React.FC = ({ objectId, selectedDetectors
- {detector.status === '#b3261e' ? 'Критическое' : - detector.status === '#fd7c22' ? 'Предупреждение' : 'Норма'} + {detector.status === statusColors.STATUS_COLOR_CRITICAL + ? 'Критическое' + : detector.status === statusColors.STATUS_COLOR_WARNING + ? 'Предупреждение' + : 'Норма'}
@@ -187,15 +191,15 @@ const DetectorList: React.FC = ({ objectId, selectedDetectors
Всего
-
{filteredDetectors.filter(d => d.status === '#00ff00').length}
+
{filteredDetectors.filter(d => d.status === statusColors.STATUS_COLOR_NORMAL).length}
Норма
-
{filteredDetectors.filter(d => d.status === '#fd7c22').length}
+
{filteredDetectors.filter(d => d.status === statusColors.STATUS_COLOR_WARNING).length}
Предупреждения
-
{filteredDetectors.filter(d => d.status === '#b3261e').length}
+
{filteredDetectors.filter(d => d.status === statusColors.STATUS_COLOR_CRITICAL).length}
Критические
@@ -209,4 +213,4 @@ const DetectorList: React.FC = ({ objectId, selectedDetectors ) } -export default DetectorList \ No newline at end of file +export default DetectorList diff --git a/frontend/components/model/ModelViewer.tsx b/frontend/components/model/ModelViewer.tsx index 0d6ed81..c051e45 100644 --- a/frontend/components/model/ModelViewer.tsx +++ b/frontend/components/model/ModelViewer.tsx @@ -12,9 +12,7 @@ import { Color4, AbstractMesh, Nullable, - HighlightLayer, - Mesh, - InstancedMesh, + HighlightLayer, Animation, CubicEase, EasingFunction, @@ -24,9 +22,19 @@ import { Matrix, } from '@babylonjs/core' import '@babylonjs/loaders' - + import SceneToolbar from './SceneToolbar'; import LoadingSpinner from '../ui/LoadingSpinner' +import { + getSensorIdFromMesh, + collectSensorMeshes, + applyHighlightToMeshes, + statusToColor3, +} from './sensorHighlight' +import { + computeSensorOverlayCircles, + hexWithAlpha, +} from './sensorHighlightOverlay' export interface ModelViewerProps { modelPath: string @@ -45,6 +53,7 @@ export interface ModelViewerProps { isSensorSelectionEnabled?: boolean onSensorPick?: (sensorId: string | null) => void highlightAllSensors?: boolean + sensorStatusMap?: Record } const ModelViewer: React.FC = ({ @@ -57,6 +66,7 @@ const ModelViewer: React.FC = ({ isSensorSelectionEnabled, onSensorPick, highlightAllSensors, + sensorStatusMap, }) => { const canvasRef = useRef(null) const engineRef = useRef>(null) @@ -74,6 +84,10 @@ const ModelViewer: React.FC = ({ const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null) const [modelReady, setModelReady] = useState(false) const [panActive, setPanActive] = useState(false); + const [webglError, setWebglError] = useState(null) + const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState< + { sensorId: string; left: number; top: number; colorHex: string }[] + >([]) const handlePan = () => setPanActive(!panActive); @@ -222,7 +236,41 @@ const ModelViewer: React.FC = ({ if (!canvasRef.current || isInitializedRef.current) return const canvas = canvasRef.current - const engine = new Engine(canvas, true, { stencil: true }) + setWebglError(null) + + let hasWebGL = false + try { + const testCanvas = document.createElement('canvas') + const gl = + testCanvas.getContext('webgl2') || + testCanvas.getContext('webgl') || + testCanvas.getContext('experimental-webgl') + hasWebGL = !!gl + } catch { + hasWebGL = false + } + + if (!hasWebGL) { + const message = 'WebGL не поддерживается в текущем окружении' + setWebglError(message) + onError?.(message) + setIsLoading(false) + setModelReady(false) + return + } + + let engine: Engine + try { + engine = new Engine(canvas, true, { stencil: true }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const message = `WebGL недоступен: ${errorMessage}` + setWebglError(message) + onError?.(message) + setIsLoading(false) + setModelReady(false) + return + } engineRef.current = engine engine.runRenderLoop(() => { @@ -260,7 +308,14 @@ const ModelViewer: React.FC = ({ fillLight.intensity = 0.3 fillLight.diffuse = new Color3(0.8, 0.8, 1) - const hl = new HighlightLayer('highlight-layer', scene) + 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 = () => { @@ -285,7 +340,7 @@ const ModelViewer: React.FC = ({ } sceneRef.current = null } - }, []) + }, [onError]) useEffect(() => { if (!isInitializedRef.current || isDisposedRef.current) { @@ -427,19 +482,30 @@ const ModelViewer: React.FC = ({ }, [modelPath, onError, onModelLoaded]) useEffect(() => { - console.log('[ModelViewer] highlightAllSensors effect triggered:', { highlightAllSensors, modelReady, sceneReady: !!sceneRef.current }) if (!sceneRef.current || isDisposedRef.current || !modelReady) return - // Если включено выделение всех сенсоров - выделяем их все if (highlightAllSensors) { - console.log('[ModelViewer] Calling highlightAllSensorMeshes()') - highlightAllSensorMeshes() + const allMeshes = importedMeshesRef.current || [] + const sensorMeshes = collectSensorMeshes(allMeshes) + applyHighlightToMeshes( + highlightLayerRef.current, + highlightedMeshesRef, + sensorMeshes, + mesh => { + const sid = getSensorIdFromMesh(mesh) + const status = sid ? sensorStatusMap?.[sid] : undefined + return statusToColor3(status ?? null) + }, + ) + chosenMeshRef.current = null + setOverlayPos(null) + setOverlayData(null) + setAllSensorsOverlayCircles([]) return } const sensorId = (focusSensorId ?? '').trim() if (!sensorId) { - console.log('[ModelViewer] Focus cleared (no Sensor_ID provided)') for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } highlightedMeshesRef.current = [] highlightLayerRef.current?.removeAllMeshes() @@ -452,7 +518,6 @@ const ModelViewer: React.FC = ({ const allMeshes = importedMeshesRef.current || [] if (allMeshes.length === 0) { - console.warn('[ModelViewer] No meshes available for sensor matching') for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } highlightedMeshesRef.current = [] highlightLayerRef.current?.removeAllMeshes() @@ -462,44 +527,8 @@ const ModelViewer: React.FC = ({ return } - const sensorMeshes = allMeshes.filter((m: any) => { - try { - return ((m.id ?? '').includes('IfcSensor') || (m.name ?? '').includes('IfcSensor')) - } catch (error) { - console.warn('[ModelViewer] Error filtering sensor mesh:', error) - return false - } - }) - - 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 ?? extras?.detector_id - if (sid != null) { - return String(sid).trim() === sensorId - } - - const monitoringSensorInstance = extras?.bonsaiPset_ARBM_PSet_MonitoringSensor_Instance - if (monitoringSensorInstance && typeof monitoringSensorInstance === 'string') { - try { - const parsedInstance = JSON.parse(monitoringSensorInstance) - const instanceSensorId = parsedInstance?.Sensor_ID - if (instanceSensorId != null) { - return String(instanceSensorId).trim() === sensorId - } - } catch (parseError) { - console.warn('[ModelViewer] Error parsing MonitoringSensor_Instance JSON:', parseError) - } - } - - return false - } catch (error) { - console.warn('[ModelViewer] Error matching sensor mesh:', error) - return false - } - }) + const sensorMeshes = collectSensorMeshes(allMeshes) + const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId) console.log('[ModelViewer] Sensor focus', { requested: sensorId, @@ -533,37 +562,19 @@ const ModelViewer: React.FC = ({ 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) { - // Переключаем группу рендеринга для предыдущего выделенного меша - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - - hl.removeAllMeshes() - if (chosen instanceof Mesh) { - chosen.renderingGroupId = 1 - highlightedMeshesRef.current.push(chosen) - hl.addMesh(chosen, new Color3(1, 1, 0)) - } else if (chosen instanceof InstancedMesh) { - // Сохраняем исходный меш для инстанса - chosen.sourceMesh.renderingGroupId = 1 - highlightedMeshesRef.current.push(chosen.sourceMesh) - 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) { - cm.renderingGroupId = 1 - highlightedMeshesRef.current.push(cm) - hl.addMesh(cm, new Color3(1, 1, 0)) - } - } - } - } + 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 (error) { - console.error('[ModelViewer] Error focusing on sensor mesh:', error) + } catch { for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } highlightedMeshesRef.current = [] highlightLayerRef.current?.removeAllMeshes() @@ -582,99 +593,6 @@ const ModelViewer: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [focusSensorId, modelReady, highlightAllSensors]) - // Помощь: извлечь Sensor_ID из метаданных меша (совпадающая логика с фокусом) - const getSensorIdFromMesh = React.useCallback((m: AbstractMesh | null): string | null => { - if (!m) return null - try { - const meta: any = (m as any)?.metadata - const extras: any = meta?.gltf?.extras ?? meta?.extras ?? (m as any)?.extras - const sid = extras?.Sensor_ID ?? extras?.sensor_id ?? extras?.SERIAL_NUMBER ?? extras?.serial_number ?? extras?.detector_id - if (sid != null) return String(sid).trim() - const monitoringSensorInstance = extras?.bonsaiPset_ARBM_PSet_MonitoringSensor_Instance - if (monitoringSensorInstance && typeof monitoringSensorInstance === 'string') { - try { - const parsedInstance = JSON.parse(monitoringSensorInstance) - const instanceSensorId = parsedInstance?.Sensor_ID - if (instanceSensorId != null) return String(instanceSensorId).trim() - } catch (parseError) { - console.warn('[ModelViewer] Error parsing MonitoringSensor_Instance JSON in pick:', parseError) - } - } - } catch { - } - return null - }, []) - - // Функция для выделения всех сенсоров на модели - const highlightAllSensorMeshes = React.useCallback(() => { - console.log('[ModelViewer] highlightAllSensorMeshes called') - const scene = sceneRef.current - if (!scene || !highlightLayerRef.current) { - console.log('[ModelViewer] Cannot highlight - scene or highlightLayer not ready:', { - sceneReady: !!scene, - highlightLayerReady: !!highlightLayerRef.current - }) - return - } - - const allMeshes = importedMeshesRef.current || [] - - // Use the same logic as getSensorIdFromMesh to identify sensor meshes - const sensorMeshes = allMeshes.filter((m: any) => { - try { - const sensorId = getSensorIdFromMesh(m) - if (sensorId) { - console.log(`[ModelViewer] Found sensor mesh: ${m.name} (id: ${m.id}, sensorId: ${sensorId})`) - return true - } - return false - } catch (error) { - console.warn('[ModelViewer] Error filtering sensor mesh:', error) - return false - } - }) - - console.log(`[ModelViewer] Found ${sensorMeshes.length} sensor meshes out of ${allMeshes.length} total meshes`) - - if (sensorMeshes.length === 0) { - console.log('[ModelViewer] No sensor meshes found to highlight') - return - } - - // Clear previous highlights - for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } - highlightedMeshesRef.current = [] - highlightLayerRef.current?.removeAllMeshes() - - // Highlight all sensor meshes - sensorMeshes.forEach((mesh: any) => { - try { - if (mesh instanceof Mesh) { - mesh.renderingGroupId = 1 - highlightedMeshesRef.current.push(mesh) - highlightLayerRef.current?.addMesh(mesh, new Color3(1, 1, 0)) - } else if (mesh instanceof InstancedMesh) { - mesh.sourceMesh.renderingGroupId = 1 - highlightedMeshesRef.current.push(mesh.sourceMesh) - highlightLayerRef.current?.addMesh(mesh.sourceMesh, new Color3(1, 1, 0)) - } else { - const children = typeof mesh.getChildMeshes === 'function' ? mesh.getChildMeshes() : [] - for (const cm of children) { - if (cm instanceof Mesh) { - cm.renderingGroupId = 1 - highlightedMeshesRef.current.push(cm) - highlightLayerRef.current?.addMesh(cm, new Color3(1, 1, 0)) - } - } - } - } catch (error) { - console.warn('[ModelViewer] Error highlighting sensor mesh:', error) - } - }) - - console.log(`[ModelViewer] Successfully highlighted ${highlightedMeshesRef.current.length} sensor meshes`) - }, [getSensorIdFromMesh]) - // Включение выбора на основе взаимодействия с моделью только при готовности модели и включении выбора сенсоров useEffect(() => { const scene = sceneRef.current @@ -701,7 +619,7 @@ const ModelViewer: React.FC = ({ return () => { scene.onPointerObservable.remove(pickObserver) } - }, [modelReady, isSensorSelectionEnabled, onSensorPick, getSensorIdFromMesh]) + }, [modelReady, isSensorSelectionEnabled, onSensorPick]) // Расчет позиции оверлея const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => { @@ -733,6 +651,41 @@ const ModelViewer: React.FC = ({ setOverlayPos(pos) }, [overlayData, computeOverlayPosition]) + useEffect(() => { + const scene = sceneRef.current + const engine = engineRef.current + if (!scene || !engine || !modelReady) { + setAllSensorsOverlayCircles([]) + return + } + if (!highlightAllSensors || focusSensorId || !sensorStatusMap) { + setAllSensorsOverlayCircles([]) + return + } + const allMeshes = importedMeshesRef.current || [] + const sensorMeshes = collectSensorMeshes(allMeshes) + if (sensorMeshes.length === 0) { + setAllSensorsOverlayCircles([]) + return + } + const engineTyped = engine as Engine + const updateCircles = () => { + const circles = computeSensorOverlayCircles({ + scene, + engine: engineTyped, + meshes: sensorMeshes, + sensorStatusMap, + }) + setAllSensorsOverlayCircles(circles) + } + updateCircles() + const observer = scene.onBeforeRenderObservable.add(updateCircles) + return () => { + scene.onBeforeRenderObservable.remove(observer) + setAllSensorsOverlayCircles([]) + } + }, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap]) + // Позиция оверлея при движущейся камере useEffect(() => { if (!sceneRef.current || !chosenMeshRef.current || !overlayData) return @@ -767,10 +720,24 @@ const ModelViewer: React.FC = ({ - {isLoading && ( + {webglError ? ( +
+
+
+ 3D просмотр недоступен +
+
+ {webglError} +
+
+ Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве +
+
+
+ ) : isLoading ? (
= ({ strokeWidth={8} />
- )} - {!modelReady && !isLoading && ( + ) : !modelReady ? (
@@ -790,7 +756,7 @@ const ModelViewer: React.FC = ({
- )} + ) : null} = ({ /> )} + {allSensorsOverlayCircles.map(circle => { + const size = 36 + const radius = size / 2 + const fill = hexWithAlpha(circle.colorHex, 0.2) + return ( +
+ ) + })} {renderOverlay && overlayPos && overlayData ? renderOverlay({ anchor: overlayPos, info: overlayData }) : null @@ -809,4 +796,4 @@ const ModelViewer: React.FC = ({ ) } -export default ModelViewer \ No newline at end of file +export default ModelViewer diff --git a/frontend/components/model/sensorHighlight.ts b/frontend/components/model/sensorHighlight.ts new file mode 100644 index 0000000..09cd3e6 --- /dev/null +++ b/frontend/components/model/sensorHighlight.ts @@ -0,0 +1,96 @@ +import { + AbstractMesh, + HighlightLayer, + Mesh, + InstancedMesh, + Color3, +} from '@babylonjs/core' +import * as statusColors from '../../lib/statusColors' + +export const SENSOR_HIGHLIGHT_COLOR = new Color3(1, 1, 0) + +const CRITICAL_COLOR3 = Color3.FromHexString(statusColors.STATUS_COLOR_CRITICAL) +const WARNING_COLOR3 = Color3.FromHexString(statusColors.STATUS_COLOR_WARNING) +const NORMAL_COLOR3 = Color3.FromHexString(statusColors.STATUS_COLOR_NORMAL) + +export const statusToColor3 = (status?: string | null): Color3 => { + if (!status) return SENSOR_HIGHLIGHT_COLOR + const lower = status.toLowerCase() + if (lower === 'critical') { + return CRITICAL_COLOR3 + } + if (lower === 'warning') { + return WARNING_COLOR3 + } + if (lower === 'info' || lower === 'normal' || lower === 'ok') { + return NORMAL_COLOR3 + } + if (status === statusColors.STATUS_COLOR_CRITICAL) return CRITICAL_COLOR3 + if (status === statusColors.STATUS_COLOR_WARNING) return WARNING_COLOR3 + if (status === statusColors.STATUS_COLOR_NORMAL) return NORMAL_COLOR3 + return SENSOR_HIGHLIGHT_COLOR +} + +export const getSensorIdFromMesh = (m: AbstractMesh | null): string | null => { + if (!m) return null + try { + const meta: any = (m as any)?.metadata + const extras: any = meta?.gltf?.extras ?? meta?.extras ?? (m as any)?.extras + const sid = + extras?.Sensor_ID ?? + extras?.sensor_id ?? + extras?.SERIAL_NUMBER ?? + extras?.serial_number ?? + extras?.detector_id + if (sid != null) return String(sid).trim() + const monitoringSensorInstance = extras?.bonsaiPset_ARBM_PSet_MonitoringSensor_Instance + if (monitoringSensorInstance && typeof monitoringSensorInstance === 'string') { + try { + const parsedInstance = JSON.parse(monitoringSensorInstance) + const instanceSensorId = parsedInstance?.Sensor_ID + if (instanceSensorId != null) return String(instanceSensorId).trim() + } catch { + return null + } + } + } catch { + return null + } + return null +} + +export const collectSensorMeshes = (meshes: AbstractMesh[]): AbstractMesh[] => { + const result: AbstractMesh[] = [] + for (const m of meshes) { + const sid = getSensorIdFromMesh(m) + if (sid) result.push(m) + } + return result +} + +export const applyHighlightToMeshes = ( + layer: HighlightLayer | null, + highlightedRef: { current: AbstractMesh[] }, + meshesToHighlight: AbstractMesh[], + getColor?: (mesh: AbstractMesh) => Color3 | null, +) => { + if (!layer) return + for (const m of highlightedRef.current) { + m.renderingGroupId = 0 + } + highlightedRef.current = [] + layer.removeAllMeshes() + + meshesToHighlight.forEach(mesh => { + const color = getColor ? getColor(mesh) ?? SENSOR_HIGHLIGHT_COLOR : SENSOR_HIGHLIGHT_COLOR + if (mesh instanceof Mesh) { + mesh.renderingGroupId = 1 + highlightedRef.current.push(mesh) + layer.addMesh(mesh, color) + } else if (mesh instanceof InstancedMesh) { + mesh.sourceMesh.renderingGroupId = 1 + highlightedRef.current.push(mesh.sourceMesh) + layer.addMesh(mesh.sourceMesh, color) + } + }) +} diff --git a/frontend/components/model/sensorHighlightOverlay.ts b/frontend/components/model/sensorHighlightOverlay.ts new file mode 100644 index 0000000..c2030af --- /dev/null +++ b/frontend/components/model/sensorHighlightOverlay.ts @@ -0,0 +1,72 @@ +import { AbstractMesh, Matrix, Vector3, Scene, Engine } from '@babylonjs/core' +import * as statusColors from '../../lib/statusColors' +import { getSensorIdFromMesh } from './sensorHighlight' + +export interface SensorOverlayCircle { + sensorId: string + left: number + top: number + colorHex: string +} + +const parseHexColor = (hex: string) => { + let clean = hex.trim().replace('#', '') + if (clean.length === 3) { + clean = clean + .split('') + .map(c => c + c) + .join('') + } + const num = parseInt(clean, 16) + const r = (num >> 16) & 255 + const g = (num >> 8) & 255 + const b = num & 255 + return { r, g, b } +} + +export const hexWithAlpha = (hex: string, alpha: number) => { + const { r, g, b } = parseHexColor(hex) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + +export const computeSensorOverlayCircles = (params: { + scene: Scene + engine: Engine + meshes: AbstractMesh[] + sensorStatusMap?: Record +}): SensorOverlayCircle[] => { + const { scene, engine, meshes, sensorStatusMap } = params + const camera = scene.activeCamera + if (!camera) return [] + const viewport = camera.viewport.toGlobal(engine.getRenderWidth(), engine.getRenderHeight()) + const result: SensorOverlayCircle[] = [] + + for (const mesh of meshes) { + const sensorId = getSensorIdFromMesh(mesh) + if (!sensorId) continue + + 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 projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport) + if (!projected) continue + + const statusColor = sensorStatusMap?.[sensorId] || statusColors.STATUS_COLOR_NORMAL + + result.push({ + sensorId, + left: projected.x, + top: projected.y, + colorHex: statusColor, + }) + } + + return result +} + diff --git a/frontend/components/navigation/AlertMenu.tsx b/frontend/components/navigation/AlertMenu.tsx index 7f465d3..3ff2033 100644 --- a/frontend/components/navigation/AlertMenu.tsx +++ b/frontend/components/navigation/AlertMenu.tsx @@ -4,6 +4,7 @@ import React from 'react' import { useRouter } from 'next/navigation' import useNavigationStore from '@/app/store/navigationStore' import AreaChart from '../dashboard/AreaChart' +import * as statusColors from '../../lib/statusColors' interface AlertType { id: number @@ -18,7 +19,7 @@ interface AlertType { acknowledged: boolean priority: string } - + interface AlertMenuProps { alert: AlertType isOpen: boolean @@ -27,7 +28,7 @@ interface AlertMenuProps { compact?: boolean anchor?: { left: number; top: number } | null } - + const AlertMenu: React.FC = ({ alert, isOpen, onClose, getStatusText, compact = false, anchor = null }) => { const router = useRouter() const { setSelectedDetector, currentObject } = useNavigationStore() @@ -56,17 +57,19 @@ const AlertMenu: React.FC = ({ alert, isOpen, onClose, getStatus } const getStatusColorCircle = (status: string) => { - // Use hex colors from Alerts submenu system - if (status === '#b3261e') return 'bg-red-500' - if (status === '#fd7c22') return 'bg-orange-500' - if (status === '#00ff00') return 'bg-green-500' + if (status === statusColors.STATUS_COLOR_CRITICAL) return 'bg-red-500' + if (status === statusColors.STATUS_COLOR_WARNING) return 'bg-orange-500' + if (status === statusColors.STATUS_COLOR_NORMAL) return 'bg-green-500' - // Fallback for text-based status switch (status?.toLowerCase()) { - case 'critical': return 'bg-red-500' - case 'warning': return 'bg-orange-500' - case 'normal': return 'bg-green-500' - default: return 'bg-gray-500' + case 'critical': + return 'bg-red-500' + case 'warning': + return 'bg-orange-500' + case 'normal': + return 'bg-green-500' + default: + return 'bg-gray-500' } } @@ -339,4 +342,4 @@ const AlertMenu: React.FC = ({ alert, isOpen, onClose, getStatus ) } -export default AlertMenu \ No newline at end of file +export default AlertMenu diff --git a/frontend/components/navigation/FloorNavigation.tsx b/frontend/components/navigation/FloorNavigation.tsx index 8bc2f1a..e7d973e 100644 --- a/frontend/components/navigation/FloorNavigation.tsx +++ b/frontend/components/navigation/FloorNavigation.tsx @@ -1,11 +1,12 @@ 'use client' - + import React, { useState } from 'react' +import * as statusColors from '../../lib/statusColors' interface DetectorsDataType { detectors: Record } - + interface FloorNavigationProps { objectId?: string detectorsData: DetectorsDataType @@ -13,7 +14,7 @@ interface FloorNavigationProps { onClose?: () => void is3DReady?: boolean } - + interface DetectorType { detector_id: number name: string @@ -34,7 +35,7 @@ interface DetectorType { priority: string }> } - + const FloorNavigation: React.FC = (props) => { const { objectId, detectorsData, onDetectorMenuClick, onClose, is3DReady = true } = props const [expandedFloors, setExpandedFloors] = useState>(new Set()) @@ -81,19 +82,27 @@ const FloorNavigation: React.FC = (props) => { const getStatusColor = (status: string) => { switch (status) { - case '#b3261e': return 'bg-red-500' - case '#fd7c22': return 'bg-orange-500' - case '#00ff00': return 'bg-green-500' - default: return 'bg-gray-500' + case statusColors.STATUS_COLOR_CRITICAL: + return 'bg-red-500' + case statusColors.STATUS_COLOR_WARNING: + return 'bg-orange-500' + case statusColors.STATUS_COLOR_NORMAL: + return 'bg-green-500' + default: + return 'bg-gray-500' } } - + const getStatusText = (status: string) => { switch (status) { - case '#b3261e': return 'Критический' - case '#fd7c22': return 'Предупреждение' - case '#00ff00': return 'Норма' - default: return 'Неизвестно' + case statusColors.STATUS_COLOR_CRITICAL: + return 'Критический' + case statusColors.STATUS_COLOR_WARNING: + return 'Предупреждение' + case statusColors.STATUS_COLOR_NORMAL: + return 'Норма' + default: + return 'Неизвестно' } } @@ -223,4 +232,4 @@ const FloorNavigation: React.FC = (props) => { ) } -export default FloorNavigation \ No newline at end of file +export default FloorNavigation diff --git a/frontend/components/navigation/ListOfDetectors.tsx b/frontend/components/navigation/ListOfDetectors.tsx index bd22612..6579111 100644 --- a/frontend/components/navigation/ListOfDetectors.tsx +++ b/frontend/components/navigation/ListOfDetectors.tsx @@ -1,6 +1,7 @@ 'use client' import React, { useState } from 'react' +import * as statusColors from '../../lib/statusColors' interface DetectorsDataType { detectors: Record @@ -58,19 +59,27 @@ const ListOfDetectors: React.FC = ({ objectId, detectorsDa const getStatusColor = (status: string) => { switch (status) { - case '#b3261e': return 'bg-red-500' - case '#fd7c22': return 'bg-orange-500' - case '#00ff00': return 'bg-green-500' - default: return 'bg-gray-500' + case statusColors.STATUS_COLOR_CRITICAL: + return 'bg-red-500' + case statusColors.STATUS_COLOR_WARNING: + return 'bg-orange-500' + case statusColors.STATUS_COLOR_NORMAL: + return 'bg-green-500' + default: + return 'bg-gray-500' } } const getStatusText = (status: string) => { switch (status) { - case '#b3261e': return 'Критический' - case '#fd7c22': return 'Предупреждение' - case '#00ff00': return 'Норма' - default: return 'Неизвестно' + case statusColors.STATUS_COLOR_CRITICAL: + return 'Критический' + case statusColors.STATUS_COLOR_WARNING: + return 'Предупреждение' + case statusColors.STATUS_COLOR_NORMAL: + return 'Норма' + default: + return 'Неизвестно' } } @@ -168,4 +177,4 @@ const ListOfDetectors: React.FC = ({ objectId, detectorsDa ) } -export default ListOfDetectors \ No newline at end of file +export default ListOfDetectors diff --git a/frontend/components/notifications/NotificationDetectorInfo.tsx b/frontend/components/notifications/NotificationDetectorInfo.tsx index e9fb6d5..fef05b2 100644 --- a/frontend/components/notifications/NotificationDetectorInfo.tsx +++ b/frontend/components/notifications/NotificationDetectorInfo.tsx @@ -1,6 +1,7 @@ 'use client' import React from 'react' +import * as statusColors from '../../lib/statusColors' interface DetectorInfoType { detector_id: number @@ -52,9 +53,9 @@ const NotificationDetectorInfo: React.FC = ({ det } const getStatusColor = (status: string) => { - if (status === '#b3261e') return 'text-red-400' - if (status === '#fd7c22') return 'text-orange-400' - if (status === '#4caf50') return 'text-green-400' + if (status === statusColors.STATUS_COLOR_CRITICAL) return 'text-red-400' + if (status === statusColors.STATUS_COLOR_WARNING) return 'text-orange-400' + if (status === statusColors.STATUS_COLOR_NORMAL || status === '#4caf50') return 'text-green-400' return 'text-gray-400' } @@ -147,9 +148,13 @@ const NotificationDetectorInfo: React.FC = ({ det style={{ backgroundColor: detectorInfo.status }} >
- {detectorInfo.status === '#b3261e' ? 'Критический' : - detectorInfo.status === '#fd7c22' ? 'Предупреждение' : - detectorInfo.status === '#4caf50' ? 'Нормальный' : 'Неизвестно'} + {detectorInfo.status === statusColors.STATUS_COLOR_CRITICAL + ? 'Критический' + : detectorInfo.status === statusColors.STATUS_COLOR_WARNING + ? 'Предупреждение' + : detectorInfo.status === statusColors.STATUS_COLOR_NORMAL || detectorInfo.status === '#4caf50' + ? 'Нормальный' + : 'Неизвестно'} @@ -181,4 +186,4 @@ const NotificationDetectorInfo: React.FC = ({ det ) } -export default NotificationDetectorInfo \ No newline at end of file +export default NotificationDetectorInfo diff --git a/frontend/components/reports/ReportsList.tsx b/frontend/components/reports/ReportsList.tsx index 27d87ad..996e440 100644 --- a/frontend/components/reports/ReportsList.tsx +++ b/frontend/components/reports/ReportsList.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useState, useMemo } from 'react'; +import * as statusColors from '../../lib/statusColors'; interface NotificationType { id: number; @@ -78,19 +79,27 @@ const ReportsList: React.FC = ({ detectorsData, initialSearchT const getStatusColor = (type: string) => { switch (type) { - case 'critical': return '#b3261e'; - case 'warning': return '#fd7c22'; - case 'info': return '#00ff00'; - default: return '#666'; + case 'critical': + return statusColors.STATUS_COLOR_CRITICAL; + case 'warning': + return statusColors.STATUS_COLOR_WARNING; + case 'info': + return statusColors.STATUS_COLOR_NORMAL; + default: + return statusColors.STATUS_COLOR_UNKNOWN; } }; const getPriorityColor = (priority: string) => { switch (priority) { - case 'high': return '#b3261e'; - case 'medium': return '#fd7c22'; - case 'low': return '#00ff00'; - default: return '#666'; + case 'high': + return statusColors.STATUS_COLOR_CRITICAL; + case 'medium': + return statusColors.STATUS_COLOR_WARNING; + case 'low': + return statusColors.STATUS_COLOR_NORMAL; + default: + return statusColors.STATUS_COLOR_UNKNOWN; } }; @@ -278,4 +287,4 @@ const ReportsList: React.FC = ({ detectorsData, initialSearchT ); }; -export default ReportsList; \ No newline at end of file +export default ReportsList; diff --git a/frontend/lib/statusColors.ts b/frontend/lib/statusColors.ts new file mode 100644 index 0000000..dc74780 --- /dev/null +++ b/frontend/lib/statusColors.ts @@ -0,0 +1,4 @@ +export const STATUS_COLOR_CRITICAL = '#b3261e' +export const STATUS_COLOR_WARNING = '#fd7c22' +export const STATUS_COLOR_NORMAL = '#00ff00' +export const STATUS_COLOR_UNKNOWN = '#666666'