feat(prediction): 다각형/원 오염분석 + 범례 최소화 + Convex Hull 면적 계산
- 오염분석 버튼을 다각형 분석 / 원 분석으로 분리 - 다각형 분석: Convex Hull(Graham Scan) + Shoelace 알고리즘으로 확산 입자 외곽 다각형 면적(km²), 둘레(km), 꼭짓점 수 계산 - 원 분석: 향후 오픈 예정 팝업 - geo.ts에 convexHull, polygonAreaKm2, analyzeSpillPolygon 함수 추가 - OilSpillView → RightPanel에 oilTrajectory prop 전달 - 지도 범례에 최소화/펼치기 토글 버튼 추가 - CheckboxLabel 중복 className 경고 수정 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
b25eccee37
커밋
fb74df5c1f
@ -1060,9 +1060,18 @@ interface MapLegendProps {
|
||||
}
|
||||
|
||||
function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLines = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) {
|
||||
const [minimized, setMinimized] = useState(false)
|
||||
|
||||
if (dispersionResult && incidentCoord) {
|
||||
return (
|
||||
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.95)] backdrop-blur-xl border border-border rounded-lg p-3.5 min-w-[200px] z-[20]">
|
||||
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.95)] backdrop-blur-xl border border-border rounded-lg min-w-[200px] z-[20]">
|
||||
{/* 헤더 + 최소화 버튼 */}
|
||||
<div className="flex items-center justify-between px-3.5 pt-3 pb-1 cursor-pointer" onClick={() => setMinimized(!minimized)}>
|
||||
<span className="text-[10px] font-bold text-text-3 uppercase tracking-wider">범례</span>
|
||||
<span className="text-[10px] text-text-3 hover:text-text-1 transition-colors">{minimized ? '▶' : '▼'}</span>
|
||||
</div>
|
||||
{!minimized && (
|
||||
<div className="px-3.5 pb-3.5">
|
||||
<div className="flex items-center gap-1.5 mb-2.5">
|
||||
<div className="text-base">📍</div>
|
||||
<div>
|
||||
@ -1108,13 +1117,21 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
|
||||
<span className="text-[9px] text-text-3">풍향 (방사형)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (oilTrajectory.length > 0) {
|
||||
return (
|
||||
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-md p-3.5 min-w-[180px] z-[20]">
|
||||
<h4 className="text-[11px] font-bold uppercase tracking-wider text-text-3 mb-2.5">범례</h4>
|
||||
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-md min-w-[180px] z-[20]">
|
||||
{/* 헤더 + 최소화 버튼 */}
|
||||
<div className="flex items-center justify-between px-3.5 pt-3 pb-1 cursor-pointer" onClick={() => setMinimized(!minimized)}>
|
||||
<h4 className="text-[11px] font-bold uppercase tracking-wider text-text-3">범례</h4>
|
||||
<span className="text-[10px] text-text-3 hover:text-text-1 transition-colors">{minimized ? '▶' : '▼'}</span>
|
||||
</div>
|
||||
{!minimized && (
|
||||
<div className="px-3.5 pb-3.5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{Array.from(selectedModels).map(model => (
|
||||
<div key={model} className="flex items-center gap-2 text-xs text-text-2">
|
||||
@ -1151,6 +1168,8 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,101 @@ const DEG2RAD = Math.PI / 180
|
||||
const RAD2DEG = 180 / Math.PI
|
||||
const EARTH_RADIUS = 6371000 // meters
|
||||
|
||||
// ============================================================
|
||||
// Convex Hull + 면적 계산
|
||||
// ============================================================
|
||||
|
||||
interface LatLon { lat: number; lon: number }
|
||||
|
||||
/** Convex Hull (Graham Scan) — 입자 좌표 배열 → 외곽 다각형 좌표 반환 */
|
||||
export function convexHull(points: LatLon[]): LatLon[] {
|
||||
if (points.length < 3) return [...points]
|
||||
|
||||
// 가장 아래(lat 최소) 점 찾기 (동일하면 lon 최소)
|
||||
const sorted = [...points].sort((a, b) => a.lat - b.lat || a.lon - b.lon)
|
||||
const pivot = sorted[0]
|
||||
|
||||
// pivot 기준 극각으로 정렬
|
||||
const rest = sorted.slice(1).sort((a, b) => {
|
||||
const angleA = Math.atan2(a.lon - pivot.lon, a.lat - pivot.lat)
|
||||
const angleB = Math.atan2(b.lon - pivot.lon, b.lat - pivot.lat)
|
||||
if (angleA !== angleB) return angleA - angleB
|
||||
// 같은 각도면 거리 순
|
||||
const dA = (a.lat - pivot.lat) ** 2 + (a.lon - pivot.lon) ** 2
|
||||
const dB = (b.lat - pivot.lat) ** 2 + (b.lon - pivot.lon) ** 2
|
||||
return dA - dB
|
||||
})
|
||||
|
||||
const hull: LatLon[] = [pivot]
|
||||
for (const p of rest) {
|
||||
while (hull.length >= 2) {
|
||||
const a = hull[hull.length - 2]
|
||||
const b = hull[hull.length - 1]
|
||||
const cross = (b.lon - a.lon) * (p.lat - a.lat) - (b.lat - a.lat) * (p.lon - a.lon)
|
||||
if (cross <= 0) hull.pop()
|
||||
else break
|
||||
}
|
||||
hull.push(p)
|
||||
}
|
||||
return hull
|
||||
}
|
||||
|
||||
/** Shoelace 공식으로 다각형 면적 계산 (km²) — 위경도 좌표를 미터 변환 후 계산 */
|
||||
export function polygonAreaKm2(polygon: LatLon[]): number {
|
||||
if (polygon.length < 3) return 0
|
||||
|
||||
// 중심 기준 위경도 → 미터 변환
|
||||
const centerLat = polygon.reduce((s, p) => s + p.lat, 0) / polygon.length
|
||||
const mPerDegLat = 111320
|
||||
const mPerDegLon = 111320 * Math.cos(centerLat * DEG2RAD)
|
||||
|
||||
const pts = polygon.map(p => ({
|
||||
x: (p.lon - polygon[0].lon) * mPerDegLon,
|
||||
y: (p.lat - polygon[0].lat) * mPerDegLat,
|
||||
}))
|
||||
|
||||
// Shoelace
|
||||
let area = 0
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const j = (i + 1) % pts.length
|
||||
area += pts[i].x * pts[j].y
|
||||
area -= pts[j].x * pts[i].y
|
||||
}
|
||||
return Math.abs(area) / 2 / 1_000_000 // m² → km²
|
||||
}
|
||||
|
||||
/** 오일 궤적 입자 → Convex Hull 외곽 다각형 + 면적 + 둘레 계산 */
|
||||
export function analyzeSpillPolygon(trajectory: LatLon[]): {
|
||||
hull: LatLon[]
|
||||
areaKm2: number
|
||||
perimeterKm: number
|
||||
particleCount: number
|
||||
} {
|
||||
if (trajectory.length < 3) {
|
||||
return { hull: [], areaKm2: 0, perimeterKm: 0, particleCount: trajectory.length }
|
||||
}
|
||||
|
||||
const hull = convexHull(trajectory)
|
||||
const areaKm2 = polygonAreaKm2(hull)
|
||||
|
||||
// 둘레 계산
|
||||
let perimeter = 0
|
||||
for (let i = 0; i < hull.length; i++) {
|
||||
const j = (i + 1) % hull.length
|
||||
perimeter += haversineDistance(
|
||||
{ lat: hull[i].lat, lon: hull[i].lon },
|
||||
{ lat: hull[j].lat, lon: hull[j].lon },
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
hull,
|
||||
areaKm2,
|
||||
perimeterKm: perimeter / 1000,
|
||||
particleCount: trajectory.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 두 좌표 간 Haversine 거리 (m) */
|
||||
export function haversineDistance(p1: BoomLineCoord, p2: BoomLineCoord): number {
|
||||
const dLat = (p2.lat - p1.lat) * DEG2RAD
|
||||
|
||||
@ -636,7 +636,7 @@ export function OilSpillView() {
|
||||
</div>
|
||||
|
||||
{/* Right Panel */}
|
||||
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} />}
|
||||
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} oilTrajectory={oilTrajectory} />}
|
||||
|
||||
{/* 재계산 모달 */}
|
||||
<RecalcModal
|
||||
|
||||
@ -1,13 +1,23 @@
|
||||
import { useState } from 'react'
|
||||
import type { PredictionDetail } from '../services/predictionApi'
|
||||
import { analyzeSpillPolygon } from '@common/utils/geo'
|
||||
|
||||
export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail }: { onOpenBacktrack?: () => void; onOpenRecalc?: () => void; onOpenReport?: () => void; detail?: PredictionDetail | null }) {
|
||||
interface RightPanelProps {
|
||||
onOpenBacktrack?: () => void
|
||||
onOpenRecalc?: () => void
|
||||
onOpenReport?: () => void
|
||||
detail?: PredictionDetail | null
|
||||
oilTrajectory?: Array<{ lat: number; lon: number; time: number }>
|
||||
}
|
||||
|
||||
export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail, oilTrajectory = [] }: RightPanelProps) {
|
||||
const vessel = detail?.vessels?.[0]
|
||||
const vessel2 = detail?.vessels?.[1]
|
||||
const spill = detail?.spill
|
||||
const insurance = vessel?.insuranceData as Array<{ type: string; insurer: string; value: string; currency: string }> | null
|
||||
const [shipExpanded, setShipExpanded] = useState(false)
|
||||
const [insuranceExpanded, setInsuranceExpanded] = useState(false)
|
||||
const [polygonResult, setPolygonResult] = useState<{ areaKm2: number; perimeterKm: number; particleCount: number; hullPoints: number } | null>(null)
|
||||
|
||||
return (
|
||||
<div className="w-[300px] min-w-[300px] bg-bg-1 border-l border-border flex flex-col">
|
||||
@ -35,9 +45,50 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail
|
||||
|
||||
{/* 오염분석 */}
|
||||
<Section title="오염분석">
|
||||
<button className="w-full py-2 px-3 bg-gradient-to-r from-purple-500 to-primary-cyan text-white rounded text-[10px] font-bold font-korean">
|
||||
📐 다각형 분석수행
|
||||
<div className="flex gap-2 mb-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (oilTrajectory.length < 3) {
|
||||
alert('확산 예측을 먼저 실행하세요.')
|
||||
return
|
||||
}
|
||||
const result = analyzeSpillPolygon(oilTrajectory)
|
||||
setPolygonResult({ areaKm2: result.areaKm2, perimeterKm: result.perimeterKm, particleCount: result.particleCount, hullPoints: result.hull.length })
|
||||
}}
|
||||
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
|
||||
onClick={() => alert('원 분석 기능은 향후 오픈 예정입니다.')}
|
||||
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(6,182,212,0.1)', border: '1px solid rgba(6,182,212,0.3)', color: 'var(--cyan)' }}>
|
||||
⭕ 원 분석
|
||||
</button>
|
||||
</div>
|
||||
{polygonResult && (
|
||||
<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="grid grid-cols-2 gap-1">
|
||||
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
|
||||
<div className="text-sm font-extrabold font-mono text-purple-400">{polygonResult.areaKm2.toFixed(2)}</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-primary-cyan">{polygonResult.perimeterKm.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-status-orange">{polygonResult.particleCount.toLocaleString()}</div>
|
||||
<div className="text-[7px] text-text-3 font-korean">분석 입자 수</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">{polygonResult.hullPoints}</div>
|
||||
<div className="text-[7px] text-text-3 font-korean">외곽 꼭짓점</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* 오염 종합 상황 */}
|
||||
@ -226,8 +277,7 @@ function CheckboxLabel({ checked, children }: { checked?: boolean; children: str
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={checked}
|
||||
className="w-[13px] h-[13px]"
|
||||
className="accent-[var(--cyan)]"
|
||||
className="w-[13px] h-[13px] accent-[var(--cyan)]"
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user