Добавлена иконка на боковой тултип для отображения/скрытия подсветки датчиков на модели

This commit is contained in:
2026-02-03 13:13:16 +03:00
parent 81b361b282
commit eccc564cc7
9 changed files with 2135 additions and 9 deletions

View File

@@ -0,0 +1,911 @@
'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,
} 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
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,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const engineRef = useRef<Nullable<Engine>>(null)
const sceneRef = useRef<Nullable<Scene>>(null)
const [isLoading, setIsLoading] = useState(false)
const [loadingProgress, setLoadingProgress] = useState(0)
const [showModel, setShowModel] = useState(false)
const isInitializedRef = useRef(false)
const isDisposedRef = useRef(false)
const importedMeshesRef = useRef<AbstractMesh[]>([])
const highlightLayerRef = useRef<HighlightLayer | null>(null)
const highlightedMeshesRef = useRef<AbstractMesh[]>([])
const chosenMeshRef = useRef<AbstractMesh | null>(null)
const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null)
const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null)
const [modelReady, setModelReady] = useState(false)
const [panActive, setPanActive] = useState(false);
const [webglError, setWebglError] = useState<string | null>(null)
const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState<
{ sensorId: string; left: number; top: number; colorHex: string }[]
>([])
// NEW: State for tracking hovered sensor in overlay circles
const [hoveredSensorId, setHoveredSensorId] = useState<string | null>(null)
const handlePan = () => setPanActive(!panActive);
useEffect(() => {
const scene = sceneRef.current;
const camera = scene?.activeCamera as ArcRotateCamera;
const canvas = canvasRef.current;
if (!scene || !camera || !canvas) {
return;
}
let observer: any = null;
if (panActive) {
camera.detachControl();
observer = scene.onPointerObservable.add((pointerInfo: PointerInfo) => {
const evt = pointerInfo.event;
if (evt.buttons === 1) {
camera.inertialPanningX -= evt.movementX / camera.panningSensibility;
camera.inertialPanningY += evt.movementY / camera.panningSensibility;
}
else if (evt.buttons === 2) {
camera.inertialAlphaOffset -= evt.movementX / camera.angularSensibilityX;
camera.inertialBetaOffset -= evt.movementY / camera.angularSensibilityY;
}
}, PointerEventTypes.POINTERMOVE);
} else {
camera.detachControl();
camera.attachControl(canvas, true);
}
return () => {
if (observer) {
scene.onPointerObservable.remove(observer);
}
if (!camera.isDisposed() && !camera.inputs.attachedToElement) {
camera.attachControl(canvas, true);
}
};
}, [panActive, sceneRef, canvasRef]);
const handleZoomIn = () => {
const camera = sceneRef.current?.activeCamera as ArcRotateCamera
if (camera) {
sceneRef.current?.stopAnimation(camera)
const ease = new CubicEase()
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT)
const frameRate = 60
const durationMs = 300
const totalFrames = Math.round((durationMs / 1000) * frameRate)
const currentRadius = camera.radius
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 0.1, currentRadius * 0.8)
Animation.CreateAndStartAnimation(
'zoomIn',
camera,
'radius',
frameRate,
totalFrames,
currentRadius,
targetRadius,
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
}
}
const handleZoomOut = () => {
const camera = sceneRef.current?.activeCamera as ArcRotateCamera
if (camera) {
sceneRef.current?.stopAnimation(camera)
const ease = new CubicEase()
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT)
const frameRate = 60
const durationMs = 300
const totalFrames = Math.round((durationMs / 1000) * frameRate)
const currentRadius = camera.radius
const targetRadius = Math.min(camera.upperRadiusLimit ?? Infinity, currentRadius * 1.2)
Animation.CreateAndStartAnimation(
'zoomOut',
camera,
'radius',
frameRate,
totalFrames,
currentRadius,
targetRadius,
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
}
}
const handleTopView = () => {
const camera = sceneRef.current?.activeCamera as ArcRotateCamera;
if (camera) {
sceneRef.current?.stopAnimation(camera);
const ease = new CubicEase();
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT);
const frameRate = 60;
const durationMs = 500;
const totalFrames = Math.round((durationMs / 1000) * frameRate);
Animation.CreateAndStartAnimation(
'topViewAlpha',
camera,
'alpha',
frameRate,
totalFrames,
camera.alpha,
Math.PI / 2,
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
);
Animation.CreateAndStartAnimation(
'topViewBeta',
camera,
'beta',
frameRate,
totalFrames,
camera.beta,
0,
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
);
}
};
// NEW: Function to handle overlay circle click
const handleOverlayCircleClick = (sensorId: string) => {
console.log('[ModelViewer] Overlay circle clicked:', sensorId)
// Find the mesh for this sensor
const allMeshes = importedMeshesRef.current || []
const sensorMeshes = collectSensorMeshes(allMeshes)
const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
if (!targetMesh) {
console.warn(`[ModelViewer] Mesh not found for sensor: ${sensorId}`)
return
}
const scene = sceneRef.current
const camera = scene?.activeCamera as ArcRotateCamera
if (!scene || !camera) return
// Calculate bounding box of the sensor mesh
const bbox = (typeof targetMesh.getHierarchyBoundingVectors === 'function')
? targetMesh.getHierarchyBoundingVectors()
: {
min: targetMesh.getBoundingInfo().boundingBox.minimumWorld,
max: targetMesh.getBoundingInfo().boundingBox.maximumWorld
}
const center = bbox.min.add(bbox.max).scale(0.5)
const size = bbox.max.subtract(bbox.min)
const maxDimension = Math.max(size.x, size.y, size.z)
// Calculate optimal camera distance
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
// Stop any current animations
scene.stopAnimation(camera)
// Setup easing
const ease = new CubicEase()
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
const frameRate = 60
const durationMs = 600 // 0.6 seconds for smooth animation
const totalFrames = Math.round((durationMs / 1000) * frameRate)
// Animate camera target position
Animation.CreateAndStartAnimation(
'camTarget',
camera,
'target',
frameRate,
totalFrames,
camera.target.clone(),
center.clone(),
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
// Animate camera radius (zoom)
Animation.CreateAndStartAnimation(
'camRadius',
camera,
'radius',
frameRate,
totalFrames,
camera.radius,
targetRadius,
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
// Call callback to display tooltip
onSensorPick?.(sensorId)
console.log('[ModelViewer] Camera animation started for sensor:', sensorId)
}
useEffect(() => {
isDisposedRef.current = false
isInitializedRef.current = false
return () => {
isDisposedRef.current = true
}
}, [])
useEffect(() => {
if (!canvasRef.current || isInitializedRef.current) return
const canvas = canvasRef.current
setWebglError(null)
let hasWebGL = false
try {
const testCanvas = document.createElement('canvas')
const gl =
testCanvas.getContext('webgl2') ||
testCanvas.getContext('webgl') ||
testCanvas.getContext('experimental-webgl')
hasWebGL = !!gl
} catch {
hasWebGL = false
}
if (!hasWebGL) {
const message = 'WebGL не поддерживается в текущем окружении'
setWebglError(message)
onError?.(message)
setIsLoading(false)
setModelReady(false)
return
}
let engine: Engine
try {
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(() => {
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)
const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene)
camera.attachControl(canvas, true)
camera.lowerRadiusLimit = 2
camera.upperRadiusLimit = 200
camera.wheelDeltaPercentage = 0.01
camera.panningSensibility = 50
camera.angularSensibilityX = 1000
camera.angularSensibilityY = 1000
const ambientLight = new HemisphericLight('ambientLight', new Vector3(0, 1, 0), scene)
ambientLight.intensity = 0.4
ambientLight.diffuse = new Color3(0.7, 0.7, 0.8)
ambientLight.specular = new Color3(0.2, 0.2, 0.3)
ambientLight.groundColor = new Color3(0.3, 0.3, 0.4)
const keyLight = new HemisphericLight('keyLight', new Vector3(1, 1, 0), scene)
keyLight.intensity = 0.6
keyLight.diffuse = new Color3(1, 1, 0.9)
keyLight.specular = new Color3(1, 1, 0.9)
const fillLight = new HemisphericLight('fillLight', new Vector3(-1, 0.5, -1), scene)
fillLight.intensity = 0.3
fillLight.diffuse = new Color3(0.8, 0.8, 1)
const hl = new HighlightLayer('highlight-layer', scene, {
mainTextureRatio: 1,
blurTextureSizeRatio: 1,
})
hl.innerGlow = false
hl.outerGlow = true
hl.blurHorizontalSize = 2
hl.blurVerticalSize = 2
highlightLayerRef.current = hl
const handleResize = () => {
if (!isDisposedRef.current) {
engine.resize()
}
}
window.addEventListener('resize', handleResize)
isInitializedRef.current = true
return () => {
isDisposedRef.current = true
isInitializedRef.current = false
window.removeEventListener('resize', handleResize)
highlightLayerRef.current?.dispose()
highlightLayerRef.current = null
if (engineRef.current) {
engineRef.current.dispose()
engineRef.current = null
}
sceneRef.current = null
}
}, [onError])
useEffect(() => {
if (!modelPath || !sceneRef.current || !engineRef.current) return
const scene = sceneRef.current
setIsLoading(true)
setLoadingProgress(0)
setShowModel(false)
setModelReady(false)
const loadModel = async () => {
try {
console.log('[ModelViewer] Starting to load model:', modelPath)
// UI элемент загрузчика (есть эффект замедленности)
const progressInterval = setInterval(() => {
setLoadingProgress(prev => {
if (prev >= 90) {
clearInterval(progressInterval)
return 90
}
return prev + Math.random() * 15
})
}, 100)
// Use the correct ImportMeshAsync signature: (url, scene, onProgress)
const result = await ImportMeshAsync(modelPath, scene, (evt) => {
if (evt.lengthComputable) {
const progress = (evt.loaded / evt.total) * 100
setLoadingProgress(progress)
console.log('[ModelViewer] Loading progress:', progress)
}
})
clearInterval(progressInterval)
if (isDisposedRef.current) {
console.log('[ModelViewer] Component disposed during load')
return
}
console.log('[ModelViewer] Model loaded successfully:', {
meshesCount: result.meshes.length,
particleSystemsCount: result.particleSystems.length,
skeletonsCount: result.skeletons.length,
animationGroupsCount: result.animationGroups.length
})
importedMeshesRef.current = result.meshes
if (result.meshes.length > 0) {
const boundingBox = result.meshes[0].getHierarchyBoundingVectors()
onModelLoaded?.({
meshes: result.meshes,
boundingBox: {
min: { x: boundingBox.min.x, y: boundingBox.min.y, z: boundingBox.min.z },
max: { x: boundingBox.max.x, y: boundingBox.max.y, z: boundingBox.max.z },
},
})
// Автоматическое кадрирование камеры для отображения всей модели
const camera = scene.activeCamera as ArcRotateCamera
if (camera) {
const center = boundingBox.min.add(boundingBox.max).scale(0.5)
const size = boundingBox.max.subtract(boundingBox.min)
const maxDimension = Math.max(size.x, size.y, size.z)
// Устанавливаем оптимальное расстояние камеры
const targetRadius = maxDimension * 2.5 // Множитель для комфортного отступа
// Плавная анимация камеры к центру модели
scene.stopAnimation(camera)
const ease = new CubicEase()
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
const frameRate = 60
const durationMs = 800 // 0.8 секунды
const totalFrames = Math.round((durationMs / 1000) * frameRate)
// Анимация позиции камеры
Animation.CreateAndStartAnimation(
'frameCameraTarget',
camera,
'target',
frameRate,
totalFrames,
camera.target.clone(),
center.clone(),
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
// Анимация зума
Animation.CreateAndStartAnimation(
'frameCameraRadius',
camera,
'radius',
frameRate,
totalFrames,
camera.radius,
targetRadius,
Animation.ANIMATIONLOOPMODE_CONSTANT,
ease
)
console.log('[ModelViewer] Camera framed to model:', { center, targetRadius, maxDimension })
}
}
setLoadingProgress(100)
setShowModel(true)
setModelReady(true)
setIsLoading(false)
} catch (error) {
if (isDisposedRef.current) return
const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка'
console.error('[ModelViewer] Error loading model:', errorMessage)
const message = `Ошибка при загрузке модели: ${errorMessage}`
onError?.(message)
setIsLoading(false)
setModelReady(false)
}
}
loadModel()
}, [modelPath, onModelLoaded, onError])
useEffect(() => {
if (!highlightAllSensors || focusSensorId || !modelReady) {
setAllSensorsOverlayCircles([])
return
}
const scene = sceneRef.current
const engine = engineRef.current
if (!scene || !engine) {
setAllSensorsOverlayCircles([])
return
}
const allMeshes = importedMeshesRef.current || []
const sensorMeshes = collectSensorMeshes(allMeshes)
if (sensorMeshes.length === 0) {
setAllSensorsOverlayCircles([])
return
}
const engineTyped = engine as Engine
const updateCircles = () => {
const circles = computeSensorOverlayCircles({
scene,
engine: engineTyped,
meshes: sensorMeshes,
sensorStatusMap,
})
setAllSensorsOverlayCircles(circles)
}
updateCircles()
const observer = scene.onBeforeRenderObservable.add(updateCircles)
return () => {
scene.onBeforeRenderObservable.remove(observer)
setAllSensorsOverlayCircles([])
}
}, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap])
useEffect(() => {
if (!highlightAllSensors || focusSensorId || !modelReady) {
return
}
const scene = sceneRef.current
if (!scene) return
const allMeshes = importedMeshesRef.current || []
if (allMeshes.length === 0) {
return
}
const sensorMeshes = collectSensorMeshes(allMeshes)
console.log('[ModelViewer] Total meshes in model:', allMeshes.length)
console.log('[ModelViewer] Sensor meshes found:', sensorMeshes.length)
// Log first 5 sensor IDs found in meshes
const sensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean).slice(0, 5)
console.log('[ModelViewer] Sample sensor IDs from meshes:', sensorIds)
if (sensorMeshes.length === 0) {
console.warn('[ModelViewer] No sensor meshes found in 3D model!')
return
}
applyHighlightToMeshes(
highlightLayerRef.current,
highlightedMeshesRef,
sensorMeshes,
mesh => {
const sid = getSensorIdFromMesh(mesh)
const status = sid ? sensorStatusMap?.[sid] : undefined
return statusToColor3(status ?? null)
},
)
}, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap])
useEffect(() => {
if (!focusSensorId || !modelReady) {
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
setAllSensorsOverlayCircles([])
return
}
const sensorId = (focusSensorId ?? '').trim()
if (!sensorId) {
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
return
}
const allMeshes = importedMeshesRef.current || []
if (allMeshes.length === 0) {
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
highlightedMeshesRef.current = []
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
return
}
const sensorMeshes = collectSensorMeshes(allMeshes)
const allSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m))
const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
console.log('[ModelViewer] Sensor focus', {
requested: sensorId,
totalImportedMeshes: allMeshes.length,
totalSensorMeshes: sensorMeshes.length,
allSensorIds: allSensorIds,
chosen: chosen ? { id: chosen.id, name: chosen.name, uniqueId: chosen.uniqueId, parent: chosen.parent?.name } : null,
source: 'result.meshes',
})
const scene = sceneRef.current!
if (chosen) {
try {
const camera = scene.activeCamera as ArcRotateCamera
const bbox = (typeof chosen.getHierarchyBoundingVectors === 'function')
? chosen.getHierarchyBoundingVectors()
: { min: chosen.getBoundingInfo().boundingBox.minimumWorld, max: chosen.getBoundingInfo().boundingBox.maximumWorld }
const center = bbox.min.add(bbox.max).scale(0.5)
const size = bbox.max.subtract(bbox.min)
const maxDimension = Math.max(size.x, size.y, size.z)
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
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)
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}
/>
</>
)}
{/* UPDATED: Interactive overlay circles with hover effects */}
{allSensorsOverlayCircles.map(circle => {
const size = 36
const radius = size / 2
const fill = hexWithAlpha(circle.colorHex, 0.2)
const isHovered = hoveredSensorId === circle.sensorId
return (
<div
key={`${circle.sensorId}-${Math.round(circle.left)}-${Math.round(circle.top)}`}
onClick={() => handleOverlayCircleClick(circle.sensorId)}
onMouseEnter={() => setHoveredSensorId(circle.sensorId)}
onMouseLeave={() => setHoveredSensorId(null)}
style={{
position: 'absolute',
left: circle.left - radius,
top: circle.top - radius,
width: size,
height: size,
borderRadius: '9999px',
border: `2px solid ${circle.colorHex}`,
backgroundColor: fill,
pointerEvents: 'auto',
cursor: 'pointer',
transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)',
transform: isHovered ? 'scale(1.4)' : 'scale(1)',
boxShadow: isHovered
? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}`
: `0 0 8px ${circle.colorHex}`,
zIndex: isHovered ? 50 : 10,
}}
title={`Датчик: ${circle.sensorId}`}
/>
)
})}
{renderOverlay && overlayPos && overlayData
? renderOverlay({ anchor: overlayPos, info: overlayData })
: null
}
</div>
)
}
export default ModelViewer