6개 대형 View(AerialView, AssetsView, ReportsView, PreScatView, AdminView, LeftPanel)를 서브탭 단위로 분할하여 모듈 경계를 명확히 함. - AerialView (2,526줄 → 8파일): MediaManagement, OilAreaAnalysis, RealtimeDrone 등 - AssetsView (2,047줄 → 8파일): AssetManagement, AssetMap, ShipInsurance 등 - ReportsView (1,596줄 → 5파일): TemplateFormEditor, ReportGenerator 등 - PreScatView (1,390줄 → 7파일): ScatLeftPanel, ScatMap, ScatPopup 등 - AdminView (1,306줄 → 7파일): UsersPanel, PermissionsPanel, MenusPanel 등 - LeftPanel (1,237줄 → 5파일): PredictionInputSection, InfoLayerSection, OilBoomSection 등 FEATURE_ID 레지스트리(common/constants/featureIds.ts) 및 감사로그 서브탭 추적 훅(useFeatureTracking) 추가. .gitignore의 scat/ → /scat/ 수정 (scat 탭 파일 추적 누락 수정) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
253 lines
13 KiB
TypeScript
253 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
|
|
interface DroneInfo {
|
|
id: string
|
|
name: string
|
|
status: 'active' | 'returning' | 'standby' | 'charging'
|
|
battery: number
|
|
altitude: number
|
|
speed: number
|
|
sensor: string
|
|
color: string
|
|
}
|
|
|
|
const drones: DroneInfo[] = [
|
|
{ id: 'D-01', name: 'DJI M300 #1', status: 'active', battery: 78, altitude: 150, speed: 12, sensor: '광학 4K', color: 'var(--blue)' },
|
|
{ id: 'D-02', name: 'DJI M300 #2', status: 'active', battery: 65, altitude: 200, speed: 8, sensor: 'IR 열화상', color: 'var(--red)' },
|
|
{ id: 'D-03', name: 'Mavic 3E', status: 'active', battery: 82, altitude: 120, speed: 15, sensor: '광학 4K', color: 'var(--purple)' },
|
|
{ id: 'D-04', name: 'DJI M30T', status: 'active', battery: 45, altitude: 180, speed: 10, sensor: '다중센서', color: 'var(--green)' },
|
|
{ id: 'D-05', name: 'DJI M300 #3', status: 'returning', battery: 12, altitude: 80, speed: 18, sensor: '광학 4K', color: 'var(--orange)' },
|
|
{ id: 'D-06', name: 'Mavic 3E #2', status: 'charging', battery: 35, altitude: 0, speed: 0, sensor: '광학 4K', color: 'var(--t3)' },
|
|
]
|
|
|
|
interface AlertItem {
|
|
time: string
|
|
type: 'warning' | 'info' | 'danger'
|
|
message: string
|
|
}
|
|
|
|
const alerts: AlertItem[] = [
|
|
{ time: '15:42', type: 'danger', message: 'D-05 배터리 부족 — 자동 복귀' },
|
|
{ time: '15:38', type: 'warning', message: '오염원 신규 탐지 (34.82°N)' },
|
|
{ time: '15:35', type: 'info', message: 'D-01~D-03 다시점 융합 완료' },
|
|
{ time: '15:30', type: 'warning', message: 'AIS OFF 선박 2척 추가 탐지' },
|
|
{ time: '15:25', type: 'info', message: 'D-04 센서 데이터 수집 시작' },
|
|
{ time: '15:20', type: 'danger', message: '유류오염 확산 속도 증가 감지' },
|
|
{ time: '15:15', type: 'info', message: '3D 재구성 시작 (불명선박-B)' },
|
|
]
|
|
|
|
export function RealtimeDrone() {
|
|
const [reconProgress, setReconProgress] = useState(0)
|
|
const [reconDone, setReconDone] = useState(false)
|
|
const [selectedDrone, setSelectedDrone] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (reconDone) return
|
|
const timer = setInterval(() => {
|
|
setReconProgress(prev => {
|
|
if (prev >= 100) {
|
|
clearInterval(timer)
|
|
setReconDone(true)
|
|
return 100
|
|
}
|
|
return prev + 2
|
|
})
|
|
}, 300)
|
|
return () => clearInterval(timer)
|
|
}, [reconDone])
|
|
|
|
const statusLabel = (s: string) => {
|
|
if (s === 'active') return { text: '비행중', cls: 'text-status-green' }
|
|
if (s === 'returning') return { text: '복귀중', cls: 'text-status-orange' }
|
|
if (s === 'charging') return { text: '충전중', cls: 'text-text-3' }
|
|
return { text: '대기', cls: 'text-text-3' }
|
|
}
|
|
|
|
const alertColor = (t: string) =>
|
|
t === 'danger' ? 'border-l-status-red bg-[rgba(239,68,68,0.05)]'
|
|
: t === 'warning' ? 'border-l-status-orange bg-[rgba(249,115,22,0.05)]'
|
|
: 'border-l-primary-blue bg-[rgba(59,130,246,0.05)]'
|
|
|
|
return (
|
|
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
|
|
{/* Map Area */}
|
|
<div className="flex-1 relative bg-bg-0 overflow-hidden">
|
|
{/* Simulated map background */}
|
|
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 50% 50%, #0c1a2e, #060c18)' }}>
|
|
{/* Grid lines */}
|
|
<div className="absolute inset-0 opacity-[0.06]" style={{ backgroundImage: 'linear-gradient(rgba(6,182,212,0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(6,182,212,0.3) 1px, transparent 1px)', backgroundSize: '60px 60px' }} />
|
|
{/* Coastline hint */}
|
|
<div className="absolute" style={{ top: '20%', left: '5%', width: '40%', height: '60%', border: '1px solid rgba(34,197,94,0.15)', borderRadius: '40% 60% 50% 30%' }} />
|
|
{/* Drone position markers */}
|
|
{drones.filter(d => d.status !== 'charging').map((d, i) => (
|
|
<div
|
|
key={d.id}
|
|
className="absolute cursor-pointer"
|
|
style={{ top: `${25 + i * 12}%`, left: `${30 + i * 10}%` }}
|
|
onClick={() => setSelectedDrone(d.id)}
|
|
>
|
|
<div className="w-3 h-3 rounded-full animate-pulse-dot" style={{ background: d.color, boxShadow: `0 0 8px ${d.color}` }} />
|
|
<div className="absolute -top-4 left-4 text-[8px] font-bold font-mono whitespace-nowrap" style={{ color: d.color }}>{d.id}</div>
|
|
</div>
|
|
))}
|
|
{/* Oil spill areas */}
|
|
<div className="absolute" style={{ top: '35%', left: '45%', width: '120px', height: '80px', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: '40% 60% 50% 40%' }} />
|
|
</div>
|
|
|
|
{/* Overlay Stats */}
|
|
<div className="absolute top-2.5 left-2.5 flex gap-1.5 z-[2]">
|
|
{[
|
|
{ label: '탐지 객체', value: '847', unit: '건', color: 'text-primary-blue' },
|
|
{ label: '식별 선박', value: '312', unit: '척', color: 'text-primary-cyan' },
|
|
{ label: 'AIS OFF', value: '14', unit: '척', color: 'text-status-red' },
|
|
{ label: '오염 탐지', value: '3', unit: '건', color: 'text-status-orange' },
|
|
].map((s, i) => (
|
|
<div key={i} className="bg-[rgba(15,21,36,0.9)] backdrop-blur-sm rounded-sm px-2.5 py-1.5 border border-border">
|
|
<div className="text-[7px] text-text-3">{s.label}</div>
|
|
<div>
|
|
<span className={`font-mono font-bold text-base ${s.color}`}>{s.value}</span>
|
|
<span className="text-[7px] text-text-3 ml-0.5">{s.unit}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 3D Reconstruction Progress */}
|
|
<div className="absolute bottom-2.5 right-2.5 bg-[rgba(15,21,36,0.9)] rounded-sm px-3 py-2 border z-[3] min-w-[175px] cursor-pointer transition-colors hover:border-primary-cyan/40" style={{ borderColor: 'rgba(6,182,212,0.18)' }}>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-[9px] font-bold text-primary-cyan">🧊 3D 재구성</span>
|
|
<span className="font-mono font-bold text-[13px] text-primary-cyan">{reconProgress}%</span>
|
|
</div>
|
|
<div className="w-full h-[3px] bg-white/[0.06] rounded-sm mb-1">
|
|
<div className="h-full rounded-sm transition-all duration-500" style={{ width: `${reconProgress}%`, background: 'linear-gradient(90deg, var(--cyan), var(--blue))' }} />
|
|
</div>
|
|
{!reconDone ? (
|
|
<div className="text-[7px] text-text-3">D-01~D-03 다각도 영상 융합중...</div>
|
|
) : (
|
|
<div className="text-[8px] font-bold text-status-green mt-0.5 animate-pulse-dot">✅ 완료 — 클릭하여 정밀분석</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Live Feed Panel */}
|
|
{selectedDrone && (() => {
|
|
const drone = drones.find(d => d.id === selectedDrone)
|
|
if (!drone) return null
|
|
return (
|
|
<div className="absolute bottom-0 left-0 right-0 bg-[rgba(15,21,36,0.95)] z-[5] border-t" style={{ borderColor: 'rgba(59,130,246,0.2)', height: 190 }}>
|
|
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border">
|
|
<div className="text-[10px] font-bold flex items-center gap-1.5" style={{ color: drone.color }}>
|
|
<div className="w-1.5 h-1.5 rounded-full animate-pulse-dot" style={{ background: drone.color }} />
|
|
{drone.id} 실시간 영상
|
|
</div>
|
|
<button onClick={() => setSelectedDrone(null)} className="w-5 h-5 rounded bg-white/5 border border-border text-text-3 text-[11px] flex items-center justify-center cursor-pointer hover:text-text-1">✕</button>
|
|
</div>
|
|
<div className="grid h-[calc(100%-30px)]" style={{ gridTemplateColumns: '1fr 180px' }}>
|
|
<div className="relative overflow-hidden" style={{ background: 'radial-gradient(ellipse at center, #0c1a2e, #060c18)' }}>
|
|
{/* Simulated video feed */}
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="text-text-3/20 text-2xl font-mono">LIVE FEED</div>
|
|
</div>
|
|
{/* HUD overlay */}
|
|
<div className="absolute top-1.5 left-2 z-[2]">
|
|
<span className="text-[11px] font-bold" style={{ color: drone.color }}>{drone.id}</span>
|
|
<span className="text-[7px] px-1 py-px rounded bg-white/[0.08] ml-1">{drone.sensor}</span>
|
|
<div className="text-[7px] text-text-3 font-mono mt-0.5">34.82°N, 128.95°E</div>
|
|
</div>
|
|
<div className="absolute top-1.5 right-2 z-[2] flex items-center gap-1 text-[8px] font-bold text-status-red">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-status-red" />REC
|
|
</div>
|
|
<div className="absolute bottom-1 left-2 z-[2] text-[7px] text-text-3">
|
|
ALT {drone.altitude}m · SPD {drone.speed}m/s · HDG 045°
|
|
</div>
|
|
</div>
|
|
<div className="p-2 overflow-auto text-[9px] border-l border-border">
|
|
<div className="font-bold text-text-2 mb-1.5 font-korean">비행 정보</div>
|
|
{[
|
|
['드론 ID', drone.id],
|
|
['기체', drone.name],
|
|
['배터리', `${drone.battery}%`],
|
|
['고도', `${drone.altitude}m`],
|
|
['속도', `${drone.speed}m/s`],
|
|
['센서', drone.sensor],
|
|
['상태', statusLabel(drone.status).text],
|
|
].map(([k, v], i) => (
|
|
<div key={i} className="flex justify-between py-0.5">
|
|
<span className="text-text-3 font-korean">{k}</span>
|
|
<span className="font-mono font-semibold text-text-1">{v}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
|
|
{/* Right Sidebar */}
|
|
<div className="w-[260px] bg-[rgba(15,21,36,0.88)] border-l border-border flex flex-col overflow-auto">
|
|
{/* Drone Swarm Status */}
|
|
<div className="p-2.5 px-3 border-b border-border">
|
|
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">군집 드론 현황 · 4/6 운용</div>
|
|
<div className="flex flex-col gap-1">
|
|
{drones.map(d => {
|
|
const st = statusLabel(d.status)
|
|
return (
|
|
<div
|
|
key={d.id}
|
|
onClick={() => d.status !== 'charging' && setSelectedDrone(d.id)}
|
|
className={`flex items-center gap-2 px-2 py-1.5 rounded-sm cursor-pointer transition-colors ${
|
|
selectedDrone === d.id ? 'bg-[rgba(6,182,212,0.08)] border border-primary-cyan/20' : 'hover:bg-white/[0.02] border border-transparent'
|
|
}`}
|
|
>
|
|
<div className="w-2 h-2 rounded-full" style={{ background: d.color }} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-[9px] font-bold" style={{ color: d.color }}>{d.id}</div>
|
|
<div className="text-[7px] text-text-3 truncate">{d.name}</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className={`text-[8px] font-semibold ${st.cls}`}>{st.text}</div>
|
|
<div className="text-[7px] font-mono text-text-3">{d.battery}%</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Multi-Angle Analysis */}
|
|
<div className="p-2.5 px-3 border-b border-border">
|
|
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">다각화 분석</div>
|
|
<div className="grid grid-cols-2 gap-1">
|
|
{[
|
|
{ icon: '🎯', label: '다시점 융합', value: '28건', sub: '360° 식별' },
|
|
{ icon: '🧊', label: '3D 재구성', value: '12건', sub: '선박+오염원' },
|
|
{ icon: '📡', label: '다센서 융합', value: '45건', sub: '광학+IR+SAR' },
|
|
{ icon: '🛢️', label: '오염원 3D', value: '3건', sub: '유류+HNS' },
|
|
].map((a, i) => (
|
|
<div key={i} className="bg-white/[0.02] rounded-sm px-1.5 py-1.5 border border-white/[0.03]">
|
|
<div className="text-[10px] mb-px">{a.icon}</div>
|
|
<div className="text-[7px] text-text-3">{a.label}</div>
|
|
<div className="text-xs font-bold font-mono text-primary-cyan my-px">{a.value}</div>
|
|
<div className="text-[6px] text-text-3">{a.sub}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Real-time Alerts */}
|
|
<div className="p-2.5 px-3 flex-1 overflow-auto">
|
|
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">실시간 경보</div>
|
|
<div className="flex flex-col gap-1">
|
|
{alerts.map((a, i) => (
|
|
<div key={i} className={`px-2 py-1.5 border-l-2 rounded-sm text-[9px] font-korean ${alertColor(a.type)}`}>
|
|
<span className="font-mono text-text-3 mr-1.5">{a.time}</span>
|
|
<span className="text-text-2">{a.message}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|