Phase 0: CSS 인프라 구축
- Tailwind config 색상 불일치 수정 (t1/t2/t3 → CSS 변수 값으로 통일)
- index.css 1,302줄 → @import 엔트리포인트 7줄로 축소
- common/styles/base.css: @layer base 추출 (CSS 변수, 리셋, body 기본값)
- common/styles/components.css: @layer components + utilities 추출
- common/styles/wing.css: wing-* 디자인 시스템 클래스 신규 정의
- common/utils/cn.ts: className 조합 유틸리티
- App.css 삭제 (내용을 components.css로 통합)
Phase 1: body default 인라인 스타일 일괄 제거
- fontFamily: 'var(--fK)' 781건 제거 (body font-family 상속)
- color: 'var(--t1)' 274건 제거 (body color 상속)
- 빈 style={{}} 78건 정리
- 31개 파일, JS 번들 23KB 감소
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
296 lines
11 KiB
TypeScript
Executable File
296 lines
11 KiB
TypeScript
Executable File
import { useRef, useEffect } from 'react'
|
|
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions } from '@common/types/backtrack'
|
|
|
|
interface BacktrackModalProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
phase: BacktrackPhase
|
|
conditions: BacktrackConditions
|
|
vessels: BacktrackVessel[]
|
|
onRunAnalysis: () => void
|
|
onStartReplay: () => void
|
|
}
|
|
|
|
export function BacktrackModal({
|
|
isOpen,
|
|
onClose,
|
|
phase,
|
|
conditions,
|
|
vessels,
|
|
onRunAnalysis,
|
|
onStartReplay,
|
|
}: BacktrackModalProps) {
|
|
const backdropRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (e.target === backdropRef.current) onClose()
|
|
}
|
|
if (isOpen) document.addEventListener('mousedown', handler)
|
|
return () => document.removeEventListener('mousedown', handler)
|
|
}, [isOpen, onClose])
|
|
|
|
if (!isOpen) return null
|
|
|
|
return (
|
|
<div
|
|
ref={backdropRef}
|
|
style={{
|
|
position: 'fixed', inset: 0, zIndex: 9999,
|
|
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(4px)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}
|
|
>
|
|
<div style={{
|
|
width: '580px', maxHeight: 'calc(100vh - 120px)',
|
|
background: 'var(--bg1)', border: '1px solid var(--bd)',
|
|
borderRadius: '14px', overflow: 'hidden',
|
|
display: 'flex', flexDirection: 'column',
|
|
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
|
}}>
|
|
{/* Header */}
|
|
<div style={{
|
|
padding: '20px 24px', borderBottom: '1px solid var(--bd)',
|
|
display: 'flex', alignItems: 'center', gap: '14px',
|
|
}}>
|
|
<div style={{
|
|
width: '40px', height: '40px', borderRadius: '10px',
|
|
background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(6,182,212,0.2))',
|
|
border: '1px solid rgba(168,85,247,0.3)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: '18px',
|
|
}}>
|
|
🔍
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<h2 style={{
|
|
fontSize: '16px', fontWeight: 700,
|
|
margin: 0,
|
|
}}>
|
|
유출유 역추적 분석
|
|
</h2>
|
|
<div style={{
|
|
fontSize: '11px', color: 'var(--t3)', marginTop: '2px',
|
|
}}>
|
|
AIS 항적 기반 유출 선박 추정
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
style={{
|
|
width: '32px', height: '32px', borderRadius: '8px',
|
|
border: '1px solid var(--bd)', background: 'var(--bg3)',
|
|
color: 'var(--t3)', fontSize: '14px', cursor: 'pointer',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
{/* Scrollable Content */}
|
|
<div style={{
|
|
flex: 1, overflowY: 'auto', padding: '20px 24px',
|
|
display: 'flex', flexDirection: 'column', gap: '16px',
|
|
}}>
|
|
{/* Analysis Conditions */}
|
|
<div>
|
|
<h3 style={{
|
|
fontSize: '12px', fontWeight: 700, color: 'var(--t2)',
|
|
marginBottom: '10px',
|
|
}}>
|
|
분석 조건
|
|
</h3>
|
|
<div style={{
|
|
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px',
|
|
}}>
|
|
{[
|
|
{ label: '유출 추정 시각', value: conditions.estimatedSpillTime },
|
|
{ label: '분석 범위', value: conditions.analysisRange },
|
|
{ label: '탐색 반경', value: conditions.searchRadius },
|
|
{ label: '유출 위치', value: `${conditions.spillLocation.lat.toFixed(4)}°N, ${conditions.spillLocation.lon.toFixed(4)}°E` },
|
|
].map((item, i) => (
|
|
<div key={i} style={{
|
|
padding: '10px 12px', background: 'var(--bg3)',
|
|
border: '1px solid var(--bd)', borderRadius: '8px',
|
|
}}>
|
|
<div style={{ fontSize: '9px', color: 'var(--t3)', marginBottom: '4px' }}>
|
|
{item.label}
|
|
</div>
|
|
<div style={{ fontSize: '12px', fontWeight: 600, fontFamily: 'var(--fM)' }}>
|
|
{item.value}
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div style={{
|
|
padding: '10px 12px', background: 'var(--bg3)',
|
|
border: '1px solid rgba(168,85,247,0.3)', borderRadius: '8px',
|
|
gridColumn: '1 / -1',
|
|
}}>
|
|
<div style={{ fontSize: '9px', color: 'var(--t3)', marginBottom: '4px' }}>
|
|
분석 대상 선박
|
|
</div>
|
|
<div style={{ fontSize: '14px', fontWeight: 700, color: 'var(--purple)', fontFamily: 'var(--fM)' }}>
|
|
{conditions.totalVessels}척 <span style={{ fontSize: '10px', fontWeight: 500, color: 'var(--t3)' }}>(AIS 수신)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Results */}
|
|
{phase === 'results' && vessels.length > 0 && (
|
|
<div>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
marginBottom: '12px',
|
|
}}>
|
|
<h3 style={{
|
|
fontSize: '12px', fontWeight: 700, color: 'var(--t2)',
|
|
margin: 0,
|
|
}}>
|
|
분석 결과
|
|
</h3>
|
|
<div style={{
|
|
padding: '4px 10px', borderRadius: '12px', fontSize: '10px', fontWeight: 700,
|
|
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)',
|
|
color: 'var(--red)',
|
|
}}>
|
|
{conditions.totalVessels}척 중 {vessels.length}척 의심
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
|
{vessels.map((v) => (
|
|
<VesselCard key={v.imo} vessel={v} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div style={{
|
|
padding: '16px 24px', borderTop: '1px solid var(--bd)',
|
|
display: 'flex', gap: '8px',
|
|
}}>
|
|
{phase === 'conditions' && (
|
|
<button
|
|
onClick={onRunAnalysis}
|
|
style={{
|
|
flex: 1, padding: '12px', fontSize: '13px', fontWeight: 700,
|
|
borderRadius: '8px', cursor: 'pointer',
|
|
background: 'linear-gradient(135deg, var(--purple), var(--cyan))',
|
|
border: 'none', color: '#fff',
|
|
}}
|
|
>
|
|
🔍 역추적 분석 실행
|
|
</button>
|
|
)}
|
|
{phase === 'analyzing' && (
|
|
<button
|
|
disabled
|
|
style={{
|
|
flex: 1, padding: '12px', fontSize: '13px', fontWeight: 700,
|
|
borderRadius: '8px',
|
|
background: 'var(--bg3)', border: '1px solid var(--bd)',
|
|
color: 'var(--purple)', cursor: 'wait',
|
|
}}
|
|
>
|
|
⏳ AIS 항적 분석중...
|
|
</button>
|
|
)}
|
|
{phase === 'results' && (
|
|
<button
|
|
onClick={onStartReplay}
|
|
style={{
|
|
flex: 1, padding: '12px', fontSize: '13px', fontWeight: 700,
|
|
borderRadius: '8px', cursor: 'pointer',
|
|
background: 'linear-gradient(135deg, var(--purple), var(--cyan))',
|
|
border: 'none', color: '#fff',
|
|
}}
|
|
>
|
|
🗺 지도에서 리플레이 보기
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
|
const probColor = vessel.probability >= 80 ? 'var(--red)' :
|
|
vessel.probability >= 20 ? 'var(--orange)' : 'var(--t3)'
|
|
|
|
return (
|
|
<div style={{
|
|
padding: '14px', background: 'var(--bg0)',
|
|
border: '1px solid var(--bd)', borderLeft: `4px solid ${vessel.color}`,
|
|
borderRadius: '10px',
|
|
}}>
|
|
{/* Header row */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '10px' }}>
|
|
<div style={{
|
|
width: '28px', height: '28px', borderRadius: '50%',
|
|
background: `${vessel.color}20`, border: `2px solid ${vessel.color}`,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: '12px', fontWeight: 800, color: vessel.color, fontFamily: 'var(--fM)',
|
|
}}>
|
|
{vessel.rank}
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: '13px', fontWeight: 700, fontFamily: 'var(--fM)' }}>
|
|
{vessel.name}
|
|
</div>
|
|
<div style={{ fontSize: '9px', color: 'var(--t3)', fontFamily: 'var(--fM)', marginTop: '2px' }}>
|
|
IMO: {vessel.imo} · {vessel.type} · {vessel.flag}
|
|
</div>
|
|
</div>
|
|
<div style={{ textAlign: 'right' }}>
|
|
<div style={{ fontSize: '22px', fontWeight: 800, color: probColor, fontFamily: 'var(--fM)', lineHeight: 1 }}>
|
|
{vessel.probability}%
|
|
</div>
|
|
<div style={{ fontSize: '8px', color: 'var(--t3)' }}>유출 확률</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats grid */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: '6px', marginBottom: vessel.description ? '8px' : 0 }}>
|
|
{[
|
|
{ label: '최근접 시각', value: vessel.closestTime },
|
|
{ label: '최근접 거리', value: `${vessel.closestDistance} NM` },
|
|
{ label: '속도 변화', value: vessel.speedChange, highlight: vessel.speedChange === '급감속' },
|
|
{ label: 'AIS 상태', value: vessel.aisStatus, highlight: vessel.aisStatus === '충돌신호' },
|
|
].map((s, i) => (
|
|
<div key={i} style={{
|
|
padding: '6px', background: 'var(--bg3)', borderRadius: '6px',
|
|
border: s.highlight ? '1px solid rgba(239,68,68,0.3)' : '1px solid var(--bd)',
|
|
}}>
|
|
<div style={{ fontSize: '8px', color: 'var(--t3)', marginBottom: '2px' }}>
|
|
{s.label}
|
|
</div>
|
|
<div style={{
|
|
fontSize: '10px', fontWeight: 600, fontFamily: 'var(--fM)',
|
|
color: s.highlight ? 'var(--red)' : 'var(--t1)',
|
|
}}>
|
|
{s.value}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Description */}
|
|
{vessel.description && (
|
|
<div style={{
|
|
padding: '8px 10px', background: 'rgba(239,68,68,0.05)',
|
|
border: '1px solid rgba(239,68,68,0.15)', borderRadius: '6px',
|
|
fontSize: '9px', color: 'var(--t2)',
|
|
lineHeight: '1.5',
|
|
}}>
|
|
{vessel.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|