Linked backend data to detectors' meshes.

This commit is contained in:
iv_vuytsik
2025-10-20 11:02:34 +03:00
parent aeda917001
commit 66e2bab683
15 changed files with 546 additions and 156 deletions

View File

@@ -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 ( return (
<div className="flex h-screen bg-[#0e111a]"> <div className="flex h-screen bg-[#0e111a]">
<Sidebar <Sidebar
@@ -218,6 +238,12 @@ const AlertsPage: React.FC = () => {
}`}> }`}>
{item.acknowledged ? 'Да' : 'Нет'} {item.acknowledged ? 'Да' : 'Нет'}
</span> </span>
<button
onClick={() => handleAcknowledgeToggle(item.id)}
className="ml-2 inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-[#2a2e3e] text-white hover:bg-[#353a4d]"
>
{item.acknowledged ? 'Снять' : 'Подтвердить'}
</button>
</td> </td>
<td className="py-4"> <td className="py-4">
<div className="text-sm text-gray-300">{new Date(item.timestamp).toLocaleString('ru-RU')}</div> <div className="text-sm text-gray-300">{new Date(item.timestamp).toLocaleString('ru-RU')}</div>

View File

@@ -10,19 +10,21 @@ import DetectorMenu from '../../../components/navigation/DetectorMenu'
import Notifications from '../../../components/notifications/Notifications' import Notifications from '../../../components/notifications/Notifications'
import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo' import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import type { ModelViewerProps } from '../../../components/model/ModelViewer'
const ModelViewer = dynamic(() => import('../../../components/model/ModelViewer'), { const ModelViewer = dynamic<ModelViewerProps>(() => import('../../../components/model/ModelViewer'), {
ssr: false, ssr: false,
loading: () => ( loading: () => (
<div className="w-full h-full flex items-center justify-center bg-[#0e111a]"> <div className="w-full h-full flex items-center justify-center bg-[#0e111a]">
<div className="text-gray-300 animate-pulse">Загрузка 3D-модуля</div> <div className="text-gray-300 animate-pulse">Загрузка 3D-модуля</div>
</div> </div>
), ),
}) })
interface DetectorType { interface DetectorType {
detector_id: number detector_id: number
name: string name: string
serial_number: string
object: string object: string
status: string status: string
checked: boolean checked: boolean
@@ -122,6 +124,13 @@ const NavigationPage: React.FC = () => {
} }
const handleDetectorMenuClick = (detector: DetectorType) => { 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) { if (selectedDetector?.detector_id === detector.detector_id && showDetectorMenu) {
setShowDetectorMenu(false) setShowDetectorMenu(false)
setSelectedDetector(null) setSelectedDetector(null)
@@ -226,12 +235,7 @@ const NavigationPage: React.FC = () => {
})()} })()}
{showFloorNavigation && showDetectorMenu && selectedDetector && ( {showFloorNavigation && showDetectorMenu && selectedDetector && (
<DetectorMenu null
detector={selectedDetector}
isOpen={showDetectorMenu}
onClose={closeDetectorMenu}
getStatusText={getStatusText}
/>
)} )}
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4"> <header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
@@ -263,6 +267,19 @@ const NavigationPage: React.FC = () => {
modelPath='/static-models/AerBIM_Monitor_ASM_HT_Viewer_Expo2017Astana_Level_+1430_custom_prop.glb' modelPath='/static-models/AerBIM_Monitor_ASM_HT_Viewer_Expo2017Astana_Level_+1430_custom_prop.glb'
onModelLoaded={handleModelLoaded} onModelLoaded={handleModelLoaded}
onError={handleModelError} onError={handleModelError}
focusSensorId={selectedDetector?.serial_number ?? null}
renderOverlay={({ anchor }) => (
selectedDetector && showDetectorMenu && anchor ? (
<DetectorMenu
detector={selectedDetector}
isOpen={true}
onClose={closeDetectorMenu}
getStatusText={getStatusText}
compact={true}
anchor={anchor}
/>
) : null
)}
/> />
</div> </div>
</div> </div>

View File

@@ -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 }
)
}
}

View File

@@ -66,6 +66,7 @@ export async function GET() {
object: objectId, object: objectId,
checked: sensor.checked ?? false, checked: sensor.checked ?? false,
location: sensor.zone ?? '', location: sensor.zone ?? '',
serial_number: sensor.serial_number ?? sensor.name ?? '',
} }
} }

View File

@@ -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 }
)
}
}

View File

@@ -4,6 +4,7 @@ import { persist } from 'zustand/middleware'
export interface DetectorType { export interface DetectorType {
detector_id: number detector_id: number
name: string name: string
serial_number: string
object: string object: string
status: string status: string
type: string type: string

View File

@@ -13,33 +13,51 @@ interface AreaChartProps {
data?: ChartDataPoint[] data?: ChartDataPoint[]
} }
const AreaChart: React.FC<AreaChartProps> = ({ className = '' }) => { const AreaChart: React.FC<AreaChartProps> = ({ 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 ( return (
<div className={`w-full h-full ${className}`}> <div className={`w-full h-full ${className}`}>
<svg className="w-full h-full" viewBox="0 0 635 200"> <svg className="w-full h-full" viewBox={`0 0 ${width} ${height}`}>
<defs> <defs>
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgb(42, 157, 144)" stopOpacity="0.3" /> <stop offset="0%" stopColor="rgb(42, 157, 144)" stopOpacity="0.3" />
<stop offset="100%" stopColor="rgb(42, 157, 144)" stopOpacity="0" /> <stop offset="100%" stopColor="rgb(42, 157, 144)" stopOpacity="0" />
</linearGradient> </linearGradient>
</defs> </defs>
<path <path d={areaPath} fill="url(#areaGradient)" />
d="M0,180 L100,120 L200,140 L300,80 L400,60 L500,100 L635,90 L635,200 L0,200 Z" <path d={linePath} stroke="rgb(42, 157, 144)" strokeWidth="2" fill="none" />
fill="url(#areaGradient)" {points.map((p, i) => (
/> <circle key={i} cx={p.x} cy={p.y} r="3" fill="rgb(42, 157, 144)" />
<path ))}
d="M0,180 L100,120 L200,140 L300,80 L400,60 L500,100 L635,90"
stroke="rgb(42, 157, 144)"
strokeWidth="2"
fill="none"
/>
<circle cx="0" cy="180" r="3" fill="rgb(42, 157, 144)" />
<circle cx="100" cy="120" r="3" fill="rgb(42, 157, 144)" />
<circle cx="200" cy="140" r="3" fill="rgb(42, 157, 144)" />
<circle cx="300" cy="80" r="3" fill="rgb(42, 157, 144)" />
<circle cx="400" cy="60" r="3" fill="rgb(42, 157, 144)" />
<circle cx="500" cy="100" r="3" fill="rgb(42, 157, 144)" />
<circle cx="635" cy="90" r="3" fill="rgb(42, 157, 144)" />
</svg> </svg>
</div> </div>
) )

View File

@@ -13,8 +13,8 @@ interface BarChartProps {
data?: ChartDataPoint[] data?: ChartDataPoint[]
} }
const BarChart: React.FC<BarChartProps> = ({ className = '' }) => { const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
const barData = [ const defaultData = [
{ value: 80, color: 'rgb(42, 157, 144)' }, { value: 80, color: 'rgb(42, 157, 144)' },
{ value: 65, color: 'rgb(42, 157, 144)' }, { value: 65, color: 'rgb(42, 157, 144)' },
{ value: 90, color: 'rgb(42, 157, 144)' }, { value: 90, color: 'rgb(42, 157, 144)' },
@@ -29,6 +29,12 @@ const BarChart: React.FC<BarChartProps> = ({ className = '' }) => {
{ value: 80, color: 'rgb(42, 157, 144)' } { 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 ( return (
<div className={`w-full h-full ${className}`}> <div className={`w-full h-full ${className}`}>
<svg className="w-full h-full" viewBox="0 0 635 200"> <svg className="w-full h-full" viewBox="0 0 635 200">
@@ -37,7 +43,7 @@ const BarChart: React.FC<BarChartProps> = ({ className = '' }) => {
const barWidth = 40 const barWidth = 40
const barSpacing = 12 const barSpacing = 12
const x = index * (barWidth + barSpacing) + 20 const x = index * (barWidth + barSpacing) + 20
const barHeight = (bar.value / 100) * 160 const barHeight = (bar.value / maxVal) * 160
const y = 180 - barHeight const y = 180 - barHeight
return ( return (

View File

@@ -15,55 +15,39 @@ const Dashboard: React.FC = () => {
const objectId = currentObject?.id const objectId = currentObject?.id
const objectTitle = currentObject?.title const objectTitle = currentObject?.title
const [detectorsArray, setDetectorsArray] = useState<any[]>([]) const [dashboardAlerts, setDashboardAlerts] = useState<any[]>([])
const [chartData, setChartData] = useState<{ timestamp: string; value: number }[]>([])
useEffect(() => { useEffect(() => {
const loadDetectors = async () => { const loadDashboard = async () => {
try { 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 if (!res.ok) return
const payload = await res.json() const payload = await res.json()
console.log('[Dashboard] GET /api/get-detectors', { status: res.status, payload }) console.log('[Dashboard] GET /api/get-dashboard', { status: res.status, payload })
const detectorsData = payload?.data?.detectors ?? {} const tableData = payload?.data?.table_data ?? []
const arr = Object.values(detectorsData).filter( const arr = (Array.isArray(tableData) ? tableData : [])
(detector: any) => (objectId ? detector.object === objectId : true) .filter((a: any) => (objectTitle ? a.object === objectTitle : true))
) setDashboardAlerts(arr as any[])
setDetectorsArray(arr as any[])
const cd = Array.isArray(payload?.data?.chart_data) ? payload.data.chart_data : []
setChartData(cd as any[])
} catch (e) { } catch (e) {
console.error('Failed to load detectors:', e) console.error('Failed to load dashboard:', e)
} }
} }
loadDetectors() loadDashboard()
}, [objectId]) }, [objectTitle])
const handleBackClick = () => { const handleBackClick = () => {
router.push('/objects') 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) => { const statusCounts = dashboardAlerts.reduce((acc: { critical: number; warning: number; normal: number }, a: any) => {
if (detector.status === '#b3261e') acc.critical++ if (a.severity === 'critical') acc.critical++
else if (detector.status === '#fd7c22') acc.warning++ else if (a.severity === 'warning') acc.warning++
else if (detector.status === '#00ff00') acc.normal++ else acc.normal++
return acc return acc
}, { critical: 0, warning: 0, normal: 0 }) }, { critical: 0, warning: 0, normal: 0 })
@@ -139,14 +123,14 @@ const Dashboard: React.FC = () => {
title="Показатель" title="Показатель"
subtitle="За последние 6 месяцев" subtitle="За последние 6 месяцев"
> >
<AreaChart /> <AreaChart data={chartData} />
</ChartCard> </ChartCard>
<ChartCard <ChartCard
title="Статистика" title="Статистика"
subtitle="Данные за период" subtitle="Данные за период"
> >
<BarChart /> <BarChart data={chartData?.map((d: any) => ({ value: d.value }))} />
</ChartCard> </ChartCard>
</div> </div>
</div> </div>
@@ -174,36 +158,26 @@ const Dashboard: React.FC = () => {
<thead> <thead>
<tr className="border-b border-gray-700"> <tr className="border-b border-gray-700">
<th className="text-left text-white font-medium py-3">Детектор</th> <th className="text-left text-white font-medium py-3">Детектор</th>
<th className="text-left text-white font-medium py-3">Статус</th> <th className="text-left text-white font-medium py-3">Сообщение</th>
<th className="text-left text-white font-medium py-3">Местоположение</th> <th className="text-left text-white font-medium py-3">Серьезность</th>
<th className="text-left text-white font-medium py-3">Проверен</th> <th className="text-left text-white font-medium py-3">Дата</th>
<th className="text-left text-white font-medium py-3">Решен</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{detectorsArray.map((detector: DetectorData) => ( {dashboardAlerts.map((alert: any) => (
<tr key={detector.detector_id} className="border-b border-gray-800"> <tr key={alert.id} className="border-b border-gray-800">
<td className="py-3 text-white text-sm">{detector.name}</td> <td className="py-3 text-white text-sm">{alert.name}</td>
<td className="py-3 text-gray-300 text-sm">{alert.message}</td>
<td className="py-3"> <td className="py-3">
<div className="flex items-center gap-2"> <span className={`text-sm ${alert.severity === 'critical' ? 'text-red-500' : alert.severity === 'warning' ? 'text-orange-500' : 'text-green-500'}`}>
<div {alert.severity === 'critical' ? 'Критическое' : alert.severity === 'warning' ? 'Предупреждение' : 'Норма'}
className={`w-3 h-3 rounded-full`} </span>
style={{ backgroundColor: detector.status }}
></div>
<span className="text-sm text-gray-300">
{detector.status === '#b3261e' ? 'Критическое' :
detector.status === '#fd7c22' ? 'Предупреждение' : 'Норма'}
</span>
</div>
</td> </td>
<td className="py-3 text-gray-400 text-sm">{detector.location}</td> <td className="py-3 text-gray-400 text-sm">{new Date(alert.created_at).toLocaleString()}</td>
<td className="py-3"> <td className="py-3">
{detector.checked ? ( {alert.resolved ? (
<div className="flex items-center gap-1"> <span className="text-sm text-green-500">Да</span>
<svg className="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span className="text-sm text-green-500">Да</span>
</div>
) : ( ) : (
<span className="text-sm text-gray-500">Нет</span> <span className="text-sm text-gray-500">Нет</span>
)} )}
@@ -217,7 +191,7 @@ const Dashboard: React.FC = () => {
{/* Статы */} {/* Статы */}
<div className="mt-6 grid grid-cols-4 gap-4"> <div className="mt-6 grid grid-cols-4 gap-4">
<div className="text-center"> <div className="text-center">
<div className="text-2xl font-bold text-white">{detectorsArray.length}</div> <div className="text-2xl font-bold text-white">{dashboardAlerts.length}</div>
<div className="text-sm text-gray-400">Всего</div> <div className="text-sm text-gray-400">Всего</div>
</div> </div>
<div className="text-center"> <div className="text-center">

View File

@@ -11,14 +11,22 @@ import {
Color4, Color4,
AbstractMesh, AbstractMesh,
Nullable, Nullable,
ImportMeshAsync ImportMeshAsync,
HighlightLayer,
Mesh,
InstancedMesh,
Animation,
CubicEase,
EasingFunction,
Matrix,
Viewport
} from '@babylonjs/core' } from '@babylonjs/core'
import '@babylonjs/loaders' import '@babylonjs/loaders'
import LoadingSpinner from '../ui/LoadingSpinner' import LoadingSpinner from '../ui/LoadingSpinner'
interface ModelViewerProps { export interface ModelViewerProps {
modelPath: string modelPath: string
onModelLoaded?: (modelData: { onModelLoaded?: (modelData: {
meshes: AbstractMesh[] meshes: AbstractMesh[]
@@ -28,12 +36,16 @@ interface ModelViewerProps {
} }
}) => void }) => 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<ModelViewerProps> = ({ const ModelViewer: React.FC<ModelViewerProps> = ({
modelPath, modelPath,
onModelLoaded, onModelLoaded,
onError onError,
focusSensorId,
renderOverlay,
}) => { }) => {
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const engineRef = useRef<Nullable<Engine>>(null) const engineRef = useRef<Nullable<Engine>>(null)
@@ -43,6 +55,12 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
const [showModel, setShowModel] = useState(false) const [showModel, setShowModel] = useState(false)
const isInitializedRef = useRef(false) const isInitializedRef = useRef(false)
const isDisposedRef = useRef(false) const isDisposedRef = useRef(false)
const importedMeshesRef = useRef<AbstractMesh[]>([])
const highlightLayerRef = useRef<HighlightLayer | null>(null)
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)
useEffect(() => { useEffect(() => {
@@ -95,6 +113,9 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
fillLight.intensity = 0.3 fillLight.intensity = 0.3
fillLight.diffuse = new Color3(0.8, 0.8, 1) fillLight.diffuse = new Color3(0.8, 0.8, 1)
const hl = new HighlightLayer('highlight-layer', scene)
highlightLayerRef.current = hl
const handleResize = () => { const handleResize = () => {
if (!isDisposedRef.current) { if (!isDisposedRef.current) {
engine.resize() engine.resize()
@@ -108,6 +129,9 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
isDisposedRef.current = true isDisposedRef.current = true
isInitializedRef.current = false isInitializedRef.current = false
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
highlightLayerRef.current?.dispose()
highlightLayerRef.current = null
if (engineRef.current) { if (engineRef.current) {
engineRef.current.dispose() engineRef.current.dispose()
engineRef.current = null engineRef.current = null
@@ -148,36 +172,13 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
try { try {
const result = await ImportMeshAsync(modelPath, sceneRef.current) const result = await ImportMeshAsync(modelPath, sceneRef.current)
importedMeshesRef.current = result.meshes
clearInterval(progressInterval) clearInterval(progressInterval)
setLoadingProgress(100) setLoadingProgress(100)
console.log('GLTF Model loaded successfully!') console.log('GLTF Model loaded successfully!')
console.log('\n=== IfcSensor Meshes ===') console.log('[ModelViewer] ImportMeshAsync result:', result)
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()
})
if (result.meshes.length > 0) { if (result.meshes.length > 0) {
const boundingBox = result.meshes[0].getHierarchyBoundingVectors() const boundingBox = result.meshes[0].getHierarchyBoundingVectors()
@@ -188,6 +189,9 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
camera.radius = maxDimension * 2 camera.radius = maxDimension * 2
camera.target = result.meshes[0].position camera.target = result.meshes[0].position
importedMeshesRef.current = result.meshes
setModelReady(true)
onModelLoaded?.({ onModelLoaded?.({
meshes: result.meshes, meshes: result.meshes,
boundingBox: { boundingBox: {
@@ -220,6 +224,113 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
requestIdleCallback(() => loadModel(), { timeout: 50 }) requestIdleCallback(() => loadModel(), { timeout: 50 })
}, [modelPath, onError, onModelLoaded]) }, [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 ( return (
<div className="w-full h-screen relative bg-gray-900 overflow-hidden"> <div className="w-full h-screen relative bg-gray-900 overflow-hidden">
<canvas <canvas
@@ -237,6 +348,16 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
/> />
</div> </div>
)} )}
{renderOverlay
? renderOverlay({ anchor: overlayPos, info: overlayData })
: (overlayData && overlayPos && (
<div className="absolute z-40 pointer-events-none" style={{ left: overlayPos.left, top: overlayPos.top }}>
<div className="rounded bg-black/70 text-white text-xs px-3 py-2 shadow-lg">
<div className="font-semibold truncate max-w-[200px]">{overlayData.name || 'Sensor'}</div>
{overlayData.sensorId && <div className="opacity-80">ID: {overlayData.sensorId}</div>}
</div>
</div>
))}
</div> </div>
) )
} }

View File

@@ -5,6 +5,7 @@ import React from 'react'
interface DetectorType { interface DetectorType {
detector_id: number detector_id: number
name: string name: string
serial_number: string
object: string object: string
status: string status: string
checked: boolean checked: boolean
@@ -18,36 +19,50 @@ interface DetectorMenuProps {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
getStatusText: (status: string) => string getStatusText: (status: string) => string
compact?: boolean
anchor?: { left: number; top: number } | null
} }
const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText }) => { const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => {
if (!isOpen) return null if (!isOpen) return null
return ( const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => (
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}> <div className={compact ? 'mt-2 space-y-1' : 'space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden'}>
<div className="h-full overflow-auto p-5"> {compact ? (
<div className="flex items-center justify-between mb-4"> <>
<h3 className="text-white text-lg font-medium"> <div className="grid grid-cols-2 gap-2">
Датч.{detector.name} <div>
</h3> <div className="text-[rgb(113,113,122)] text-[11px]">Маркировка по проекту</div>
<div className="flex items-center gap-2"> <div className="text-white text-xs truncate">{detector.name}</div>
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2"> </div>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> <div className="text-[rgb(113,113,122)] text-[11px]">Тип детектора</div>
</svg> <div className="text-white text-xs truncate">{detector.type}</div>
Отчет </div>
</button>
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
История
</button>
</div> </div>
</div> <div className="grid grid-cols-2 gap-2">
<div>
{/* Таблица детекторов */} <div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
<div className="space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden"> <div className="text-white text-xs truncate">{detector.location}</div>
</div>
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Статус</div>
<div className="text-white text-xs truncate">{getStatusText(detector.status)}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
<div className="text-white text-xs truncate">Сегодня, 14:30</div>
</div>
<div>
<div className="text-[rgb(113,113,122)] text-[11px]">Этаж</div>
<div className="text-white text-xs truncate">{detector.floor}</div>
</div>
</div>
</>
) : (
<>
<div className="flex"> <div className="flex">
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]"> <div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div> <div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
@@ -77,7 +92,70 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
<div className="text-white text-sm text-right">Вчера</div> <div className="text-white text-sm text-right">Вчера</div>
</div> </div>
</div> </div>
</>
)}
</div>
)
if (compact && anchor) {
return (
<div className="absolute z-40" style={{ left: anchor.left, top: anchor.top }}>
<div className="rounded-[10px] bg-black/80 text-white text-xs px-3 py-2 shadow-xl min-w-[240px] max-w-[300px]">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="font-semibold truncate">Датч.{detector.name}</div>
<div className="opacity-80">{getStatusText(detector.status)}</div>
</div>
<button onClick={onClose} className="text-gray-300 hover:text-white transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Отчет
</button>
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
История
</button>
</div>
<DetailsSection compact={true} />
</div> </div>
</div>
)
}
return (
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
<div className="h-full overflow-auto p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-white text-lg font-medium">
Датч.{detector.name}
</h3>
<div className="flex items-center gap-2">
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Отчет
</button>
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
История
</button>
</div>
</div>
<DetailsSection />
<button <button
onClick={onClose} onClick={onClose}

View File

@@ -16,6 +16,7 @@ interface FloorNavigationProps {
interface DetectorType { interface DetectorType {
detector_id: number detector_id: number
name: string name: string
serial_number: string
object: string object: string
status: string status: string
checked: boolean checked: boolean

View File

@@ -20,6 +20,7 @@ interface NotificationType {
interface DetectorType { interface DetectorType {
detector_id: number detector_id: number
name: string name: string
serial_number: string
object: string object: string
status: string status: string
type: string type: string

View File

@@ -18,6 +18,7 @@ interface NotificationType {
interface DetectorType { interface DetectorType {
detector_id: number detector_id: number
name: string name: string
serial_number: string
object: string object: string
status: string status: string
type: string type: string

View File

@@ -1,6 +1,7 @@
export interface DetectorType { export interface DetectorType {
detector_id: number detector_id: number
name: string name: string
serial_number: string
object: string object: string
status: string status: string
checked: boolean checked: boolean