feat(prediction): 오염분석 UI 개선 — HTML 디자인 참고 반영

- 다각형/원 분석 탭 버튼 사이즈 축소 + 활성 탭 스타일 통일
- 다각형 분석: 설명 텍스트 + 그라데이션 "다각형 분석수행" 버튼
- 원 분석: 반경(NM) 프리셋 버튼(1,3,5,10,15,20,30,50) + 직접 입력
  사고지점 기준 원형 영역 면적 계산 (NM² + km²)
- 분석 결과: NM²/km² 면적, 원 둘레, 반경 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nan Kyung Lee 2026-03-16 08:55:23 +09:00
부모 6944a9e342
커밋 97e9d58cc1

파일 보기

@ -18,11 +18,10 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail
const [shipExpanded, setShipExpanded] = useState(false) const [shipExpanded, setShipExpanded] = useState(false)
const [insuranceExpanded, setInsuranceExpanded] = useState(false) const [insuranceExpanded, setInsuranceExpanded] = useState(false)
const [polygonResult, setPolygonResult] = useState<{ areaKm2: number; perimeterKm: number; particleCount: number; hullPoints: number } | null>(null) const [polygonResult, setPolygonResult] = useState<{ areaKm2: number; perimeterKm: number; particleCount: number; hullPoints: number } | null>(null)
const [showCircleInput, setShowCircleInput] = useState(false) const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
const [circleCenterLat, setCircleCenterLat] = useState('') const [circleRadiusNm, setCircleRadiusNm] = useState('5')
const [circleCenterLon, setCircleCenterLon] = useState('') const [circleResult, setCircleResult] = useState<{ areaKm2: number; areaNm2: number; circumferenceKm: number; radiusNm: number } | null>(null)
const [circleRadiusKm, setCircleRadiusKm] = useState('') const NM_PRESETS = [1, 3, 5, 10, 15, 20, 30, 50]
const [circleResult, setCircleResult] = useState<{ areaKm2: number; circumferenceKm: number; centerLat: number; centerLon: number; radiusKm: number } | null>(null)
return ( return (
<div className="w-[300px] min-w-[300px] bg-bg-1 border-l border-border flex flex-col"> <div className="w-[300px] min-w-[300px] bg-bg-1 border-l border-border flex flex-col">
@ -50,131 +49,136 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail
{/* 오염분석 */} {/* 오염분석 */}
<Section title="오염분석"> <Section title="오염분석">
<div className="flex gap-2 mb-2"> {/* 탭 버튼 */}
<div className="flex gap-1.5 mb-2.5">
<button <button
onClick={() => { onClick={() => setAnalysisTab('polygon')}
if (oilTrajectory.length < 3) { className="flex-1 py-1.5 px-2 rounded text-[10px] font-semibold font-korean cursor-pointer transition-colors"
alert('확산 예측을 먼저 실행하세요.') style={analysisTab === 'polygon'
return ? { background: 'rgba(6,182,212,0.12)', border: '1px solid var(--cyan)', color: 'var(--cyan)' }
} : { background: 'var(--bg0)', border: '1px solid var(--bd)', color: 'var(--t3)' }
const result = analyzeSpillPolygon(oilTrajectory) }
setPolygonResult({ areaKm2: result.areaKm2, perimeterKm: result.perimeterKm, particleCount: result.particleCount, hullPoints: result.hull.length }) > </button>
}}
className="flex-1 py-2.5 px-2 rounded text-[10px] font-bold font-korean border cursor-pointer transition-colors hover:brightness-110"
style={{ background: 'rgba(168,85,247,0.1)', border: '1px solid rgba(168,85,247,0.3)', color: '#a855f7' }}>
📐
</button>
<button <button
onClick={() => { onClick={() => setAnalysisTab('circle')}
setShowCircleInput(!showCircleInput) className="flex-1 py-1.5 px-2 rounded text-[10px] font-semibold font-korean cursor-pointer transition-colors"
// 사고 지점 좌표를 기본값으로 설정 style={analysisTab === 'circle'
if (!showCircleInput && detail?.spill) { ? { background: 'rgba(6,182,212,0.12)', border: '1px solid var(--cyan)', color: 'var(--cyan)' }
if (!circleCenterLat) setCircleCenterLat(String(detail.spill.lat ?? '')) : { background: 'var(--bg0)', border: '1px solid var(--bd)', color: 'var(--t3)' }
if (!circleCenterLon) setCircleCenterLon(String(detail.spill.lon ?? '')) }
} > </button>
}}
className="flex-1 py-2.5 px-2 rounded text-[10px] font-bold font-korean border cursor-pointer transition-colors hover:brightness-110"
style={showCircleInput
? { background: 'rgba(6,182,212,0.2)', border: '1px solid var(--cyan)', color: 'var(--cyan)' }
: { background: 'rgba(6,182,212,0.1)', border: '1px solid rgba(6,182,212,0.3)', color: 'var(--cyan)' }
}>
</button>
</div> </div>
{/* 원 분석 입력 폼 */} {/* ── 다각형 분석 탭 ── */}
{showCircleInput && ( {analysisTab === 'polygon' && (
<div className="flex flex-col gap-2 p-2.5 bg-bg-0 border border-[rgba(6,182,212,0.2)] rounded-md mb-2"> <>
<div className="text-[9px] font-bold text-primary-cyan font-korean"> · </div> <p className="text-[9px] text-text-3 font-korean leading-relaxed mb-2">
<div className="grid grid-cols-2 gap-1.5"> .
<div> </p>
<label className="text-[8px] text-text-3 font-korean block mb-0.5"> (°N)</label> <button
<input onClick={() => {
type="number" if (oilTrajectory.length < 3) {
step="0.0001" alert('확산 예측을 먼저 실행하세요.')
value={circleCenterLat} return
onChange={e => setCircleCenterLat(e.target.value)} }
placeholder="예: 34.7312" const result = analyzeSpillPolygon(oilTrajectory)
className="w-full px-2 py-1.5 bg-bg-3 border border-border rounded text-[10px] font-mono text-text-1 outline-none focus:border-[var(--cyan)] transition-colors" setPolygonResult({ areaKm2: result.areaKm2, perimeterKm: result.perimeterKm, particleCount: result.particleCount, hullPoints: result.hull.length })
/> }}
</div> className="w-full py-2 px-3 rounded text-[10px] font-bold font-korean cursor-pointer"
<div> style={{ background: 'linear-gradient(to right, #a855f7, var(--cyan))', color: '#fff' }}
<label className="text-[8px] text-text-3 font-korean block mb-0.5"> (°E)</label> >
<input 📐
type="number" </button>
step="0.0001" </>
value={circleCenterLon} )}
onChange={e => setCircleCenterLon(e.target.value)}
placeholder="예: 127.6845" {/* ── 원 분석 탭 ── */}
className="w-full px-2 py-1.5 bg-bg-3 border border-border rounded text-[10px] font-mono text-text-1 outline-none focus:border-[var(--cyan)] transition-colors" {analysisTab === 'circle' && (
/> <>
</div> <p className="text-[9px] text-text-3 font-korean leading-relaxed mb-2">
(NM) .
</p>
{/* 반경 선택 (NM) */}
<div className="text-[9px] font-bold text-text-2 font-korean mb-1.5"> (NM)</div>
<div className="grid grid-cols-6 gap-1 mb-2">
{NM_PRESETS.map(nm => (
<button
key={nm}
onClick={() => setCircleRadiusNm(String(nm))}
className="py-1.5 rounded text-[10px] font-bold font-mono cursor-pointer transition-colors"
style={parseFloat(circleRadiusNm) === nm
? { background: 'rgba(6,182,212,0.2)', border: '1px solid var(--cyan)', color: 'var(--cyan)' }
: { background: 'var(--bg0)', border: '1px solid var(--bd)', color: 'var(--t3)' }
}
>{nm}</button>
))}
</div> </div>
<div>
<label className="text-[8px] text-text-3 font-korean block mb-0.5"> (km)</label> {/* 직접 입력 + 분석 실행 */}
<div className="flex items-center gap-2">
<span className="text-[9px] text-text-3 font-korean shrink-0"> </span>
<input <input
type="number" type="number"
step="0.1" step="0.1"
min="0.1" min="0.1"
value={circleRadiusKm} value={circleRadiusNm}
onChange={e => setCircleRadiusKm(e.target.value)} onChange={e => setCircleRadiusNm(e.target.value)}
placeholder="예: 3.0" className="w-[60px] px-2 py-1.5 bg-bg-3 border border-border rounded text-[10px] font-mono text-text-1 text-center outline-none focus:border-[var(--cyan)] transition-colors"
className="w-full px-2 py-1.5 bg-bg-3 border border-border rounded text-[10px] font-mono text-text-1 outline-none focus:border-[var(--cyan)] transition-colors"
/> />
<span className="text-[9px] text-text-3 font-korean shrink-0">NM</span>
<button
onClick={() => {
const nm = parseFloat(circleRadiusNm)
if (isNaN(nm) || nm <= 0) {
alert('반경을 올바르게 입력하세요.')
return
}
const km = nm * 1.852
const areaKm2 = Math.PI * km * km
const areaNm2 = Math.PI * nm * nm
const circumferenceKm = 2 * Math.PI * km
setCircleResult({ areaKm2, areaNm2, circumferenceKm, radiusNm: nm })
}}
disabled={!circleRadiusNm || parseFloat(circleRadiusNm) <= 0}
className="ml-auto px-3 py-1.5 rounded text-[10px] font-bold font-korean cursor-pointer shrink-0 transition-colors"
style={{
background: 'rgba(6,182,212,0.15)',
border: '1px solid var(--cyan)',
color: 'var(--cyan)',
}}
> </button>
</div> </div>
<button </>
onClick={() => {
const lat = parseFloat(circleCenterLat)
const lon = parseFloat(circleCenterLon)
const r = parseFloat(circleRadiusKm)
if (isNaN(lat) || isNaN(lon) || isNaN(r) || r <= 0) {
alert('중심 좌표와 반경을 올바르게 입력하세요.')
return
}
const areaKm2 = Math.PI * r * r
const circumferenceKm = 2 * Math.PI * r
setCircleResult({ areaKm2, circumferenceKm, centerLat: lat, centerLon: lon, radiusKm: r })
}}
disabled={!circleCenterLat || !circleCenterLon || !circleRadiusKm}
className="w-full py-2 rounded text-[10px] font-bold font-korean cursor-pointer transition-colors"
style={{
background: (circleCenterLat && circleCenterLon && circleRadiusKm) ? 'rgba(6,182,212,0.15)' : 'var(--bg3)',
border: (circleCenterLat && circleCenterLon && circleRadiusKm) ? '1px solid var(--cyan)' : '1px solid var(--bd)',
color: (circleCenterLat && circleCenterLon && circleRadiusKm) ? 'var(--cyan)' : 'var(--t3)',
}}
>
</button>
</div>
)} )}
{/* 원 분석 결과 */} {/* 원 분석 결과 */}
{circleResult && ( {analysisTab === 'circle' && circleResult && (
<div className="flex flex-col gap-1.5 mb-2"> <div className="flex flex-col gap-1.5 mt-2.5">
<div className="text-[9px] font-bold text-primary-cyan font-korean mb-0.5"> </div> <div className="text-[9px] font-bold text-primary-cyan font-korean"> ( {circleResult.radiusNm} NM)</div>
<div className="grid grid-cols-2 gap-1"> <div className="grid grid-cols-2 gap-1">
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded"> <div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-sm font-extrabold font-mono text-primary-cyan">{circleResult.areaKm2.toFixed(2)}</div> <div className="text-sm font-extrabold font-mono text-primary-cyan">{circleResult.areaNm2.toFixed(1)}</div>
<div className="text-[7px] text-text-3 font-korean"> (km²)</div> <div className="text-[7px] text-text-3 font-korean"> (NM²)</div>
</div> </div>
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded"> <div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-sm font-extrabold font-mono text-status-orange">{circleResult.circumferenceKm.toFixed(1)}</div> <div className="text-sm font-extrabold font-mono text-status-orange">{circleResult.areaKm2.toFixed(1)}</div>
<div className="text-[7px] text-text-3 font-korean"> (km²)</div>
</div>
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-sm font-extrabold font-mono text-text-1">{circleResult.circumferenceKm.toFixed(1)}</div>
<div className="text-[7px] text-text-3 font-korean"> (km)</div> <div className="text-[7px] text-text-3 font-korean"> (km)</div>
</div> </div>
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded"> <div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-sm font-extrabold font-mono text-text-1">{circleResult.radiusKm.toFixed(1)}</div> <div className="text-sm font-extrabold font-mono text-text-1">{(circleResult.radiusNm * 1.852).toFixed(1)}</div>
<div className="text-[7px] text-text-3 font-korean"> (km)</div> <div className="text-[7px] text-text-3 font-korean"> (km)</div>
</div> </div>
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-[9px] font-bold font-mono text-text-2">{circleResult.centerLat.toFixed(4)}°N</div>
<div className="text-[9px] font-bold font-mono text-text-2">{circleResult.centerLon.toFixed(4)}°E</div>
<div className="text-[7px] text-text-3 font-korean"> </div>
</div>
</div> </div>
</div> </div>
)} )}
{polygonResult && ( {/* 다각형 분석 결과 */}
{analysisTab === 'polygon' && polygonResult && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<div className="text-[9px] font-bold text-purple-400 font-korean mb-0.5">📐 Convex Hull </div> <div className="text-[9px] font-bold text-purple-400 font-korean mb-0.5">📐 Convex Hull </div>
<div className="grid grid-cols-2 gap-1"> <div className="grid grid-cols-2 gap-1">