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,96 +1060,115 @@ interface MapLegendProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLines = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) {
|
function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLines = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) {
|
||||||
|
const [minimized, setMinimized] = useState(false)
|
||||||
|
|
||||||
if (dispersionResult && incidentCoord) {
|
if (dispersionResult && incidentCoord) {
|
||||||
return (
|
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 gap-1.5 mb-2.5">
|
{/* 헤더 + 최소화 버튼 */}
|
||||||
<div className="text-base">📍</div>
|
<div className="flex items-center justify-between px-3.5 pt-3 pb-1 cursor-pointer" onClick={() => setMinimized(!minimized)}>
|
||||||
<div>
|
<span className="text-[10px] font-bold text-text-3 uppercase tracking-wider">범례</span>
|
||||||
<h4 className="text-[11px] font-bold text-primary-orange">사고 위치</h4>
|
<span className="text-[10px] text-text-3 hover:text-text-1 transition-colors">{minimized ? '▶' : '▼'}</span>
|
||||||
<div className="text-[8px] text-text-3 font-mono">
|
</div>
|
||||||
{incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E
|
{!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>
|
||||||
|
<h4 className="text-[11px] font-bold text-primary-orange">사고 위치</h4>
|
||||||
|
<div className="text-[8px] text-text-3 font-mono">
|
||||||
|
{incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-text-2 mb-2 rounded" style={{ background: 'rgba(249,115,22,0.08)', padding: '8px' }}>
|
||||||
|
<div className="flex justify-between mb-[3px]">
|
||||||
|
<span className="text-text-3">물질</span>
|
||||||
|
<span className="font-semibold text-status-orange">{dispersionResult.substance}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mb-[3px]">
|
||||||
|
<span className="text-text-3">풍향</span>
|
||||||
|
<span className="font-semibold font-mono">SW {dispersionResult.windDirection}°</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-3">확산 구역</span>
|
||||||
|
<span className="font-semibold text-primary-cyan">{dispersionResult.zones.length}개</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="text-[9px] font-bold text-text-3 mb-2">위험 구역</h5>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-text-2">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(239,68,68,0.7)' }} />
|
||||||
|
<span>치명적 위험 구역 (AEGL-3)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-text-2">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(249,115,22,0.7)' }} />
|
||||||
|
<span>높은 위험 구역 (AEGL-2)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-text-2">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(234,179,8,0.7)' }} />
|
||||||
|
<span>중간 위험 구역 (AEGL-1)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-2 rounded" style={{ padding: '6px', background: 'rgba(168,85,247,0.08)' }}>
|
||||||
|
<div className="text-xs">🧭</div>
|
||||||
|
<span className="text-[9px] text-text-3">풍향 (방사형)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="text-[9px] text-text-2 mb-2 rounded" style={{ background: 'rgba(249,115,22,0.08)', padding: '8px' }}>
|
|
||||||
<div className="flex justify-between mb-[3px]">
|
|
||||||
<span className="text-text-3">물질</span>
|
|
||||||
<span className="font-semibold text-status-orange">{dispersionResult.substance}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between mb-[3px]">
|
|
||||||
<span className="text-text-3">풍향</span>
|
|
||||||
<span className="font-semibold font-mono">SW {dispersionResult.windDirection}°</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-text-3">확산 구역</span>
|
|
||||||
<span className="font-semibold text-primary-cyan">{dispersionResult.zones.length}개</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h5 className="text-[9px] font-bold text-text-3 mb-2">위험 구역</h5>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="flex items-center gap-2 text-[10px] text-text-2">
|
|
||||||
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(239,68,68,0.7)' }} />
|
|
||||||
<span>치명적 위험 구역 (AEGL-3)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-[10px] text-text-2">
|
|
||||||
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(249,115,22,0.7)' }} />
|
|
||||||
<span>높은 위험 구역 (AEGL-2)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-[10px] text-text-2">
|
|
||||||
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(234,179,8,0.7)' }} />
|
|
||||||
<span>중간 위험 구역 (AEGL-1)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 mt-2 rounded" style={{ padding: '6px', background: 'rgba(168,85,247,0.08)' }}>
|
|
||||||
<div className="text-xs">🧭</div>
|
|
||||||
<span className="text-[9px] text-text-3">풍향 (방사형)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oilTrajectory.length > 0) {
|
if (oilTrajectory.length > 0) {
|
||||||
return (
|
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]">
|
<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]">
|
||||||
<h4 className="text-[11px] font-bold uppercase tracking-wider text-text-3 mb-2.5">범례</h4>
|
{/* 헤더 + 최소화 버튼 */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex items-center justify-between px-3.5 pt-3 pb-1 cursor-pointer" onClick={() => setMinimized(!minimized)}>
|
||||||
{Array.from(selectedModels).map(model => (
|
<h4 className="text-[11px] font-bold uppercase tracking-wider text-text-3">범례</h4>
|
||||||
<div key={model} className="flex items-center gap-2 text-xs text-text-2">
|
<span className="text-[10px] text-text-3 hover:text-text-1 transition-colors">{minimized ? '▶' : '▼'}</span>
|
||||||
<div className="w-3.5 h-3.5 rounded-full" style={{ background: MODEL_COLORS[model] }} />
|
</div>
|
||||||
<span className="font-korean">{model}</span>
|
{!minimized && (
|
||||||
</div>
|
<div className="px-3.5 pb-3.5">
|
||||||
))}
|
<div className="flex flex-col gap-1.5">
|
||||||
{selectedModels.size === 3 && (
|
{Array.from(selectedModels).map(model => (
|
||||||
<div className="flex items-center gap-2 text-[9px] text-text-3">
|
<div key={model} className="flex items-center gap-2 text-xs text-text-2">
|
||||||
<span className="font-korean">(앙상블 모드)</span>
|
<div className="w-3.5 h-3.5 rounded-full" style={{ background: MODEL_COLORS[model] }} />
|
||||||
</div>
|
<span className="font-korean">{model}</span>
|
||||||
)}
|
</div>
|
||||||
<div className="h-px bg-border my-1" />
|
))}
|
||||||
<div className="flex items-center gap-2 text-xs text-text-2">
|
{selectedModels.size === 3 && (
|
||||||
<div className="w-3.5 h-3.5 rounded-full bg-primary-cyan" />
|
<div className="flex items-center gap-2 text-[9px] text-text-3">
|
||||||
<span className="font-korean">사고 지점</span>
|
<span className="font-korean">(앙상블 모드)</span>
|
||||||
</div>
|
</div>
|
||||||
{boomLines.length > 0 && (
|
)}
|
||||||
<>
|
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<div className="flex items-center gap-2 text-xs text-text-2">
|
<div className="flex items-center gap-2 text-xs text-text-2">
|
||||||
<div className="w-[14px] h-[3px] bg-[#ef4444] rounded-[1px]" />
|
<div className="w-3.5 h-3.5 rounded-full bg-primary-cyan" />
|
||||||
<span className="font-korean">긴급 오일펜스</span>
|
<span className="font-korean">사고 지점</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-text-2">
|
{boomLines.length > 0 && (
|
||||||
<div className="w-[14px] h-[3px] bg-[#f97316] rounded-[1px]" />
|
<>
|
||||||
<span className="font-korean">중요 오일펜스</span>
|
<div className="h-px bg-border my-1" />
|
||||||
</div>
|
<div className="flex items-center gap-2 text-xs text-text-2">
|
||||||
<div className="flex items-center gap-2 text-xs text-text-2">
|
<div className="w-[14px] h-[3px] bg-[#ef4444] rounded-[1px]" />
|
||||||
<div className="w-[14px] h-[3px] bg-[#eab308] rounded-[1px]" />
|
<span className="font-korean">긴급 오일펜스</span>
|
||||||
<span className="font-korean">보통 오일펜스</span>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-2 text-xs text-text-2">
|
||||||
</>
|
<div className="w-[14px] h-[3px] bg-[#f97316] rounded-[1px]" />
|
||||||
)}
|
<span className="font-korean">중요 오일펜스</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-text-2">
|
||||||
|
<div className="w-[14px] h-[3px] bg-[#eab308] rounded-[1px]" />
|
||||||
|
<span className="font-korean">보통 오일펜스</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,101 @@ const DEG2RAD = Math.PI / 180
|
|||||||
const RAD2DEG = 180 / Math.PI
|
const RAD2DEG = 180 / Math.PI
|
||||||
const EARTH_RADIUS = 6371000 // meters
|
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) */
|
/** 두 좌표 간 Haversine 거리 (m) */
|
||||||
export function haversineDistance(p1: BoomLineCoord, p2: BoomLineCoord): number {
|
export function haversineDistance(p1: BoomLineCoord, p2: BoomLineCoord): number {
|
||||||
const dLat = (p2.lat - p1.lat) * DEG2RAD
|
const dLat = (p2.lat - p1.lat) * DEG2RAD
|
||||||
|
|||||||
@ -636,7 +636,7 @@ export function OilSpillView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Panel */}
|
{/* 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
|
<RecalcModal
|
||||||
|
|||||||
@ -1,13 +1,23 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { PredictionDetail } from '../services/predictionApi'
|
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 vessel = detail?.vessels?.[0]
|
||||||
const vessel2 = detail?.vessels?.[1]
|
const vessel2 = detail?.vessels?.[1]
|
||||||
const spill = detail?.spill
|
const spill = detail?.spill
|
||||||
const insurance = vessel?.insuranceData as Array<{ type: string; insurer: string; value: string; currency: string }> | null
|
const insurance = vessel?.insuranceData as Array<{ type: string; insurer: string; value: string; currency: string }> | null
|
||||||
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)
|
||||||
|
|
||||||
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">
|
||||||
@ -35,9 +45,50 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail
|
|||||||
|
|
||||||
{/* 오염분석 */}
|
{/* 오염분석 */}
|
||||||
<Section title="오염분석">
|
<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
|
||||||
</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>
|
</Section>
|
||||||
|
|
||||||
{/* 오염 종합 상황 */}
|
{/* 오염 종합 상황 */}
|
||||||
@ -226,8 +277,7 @@ function CheckboxLabel({ checked, children }: { checked?: boolean; children: str
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
defaultChecked={checked}
|
defaultChecked={checked}
|
||||||
className="w-[13px] h-[13px]"
|
className="w-[13px] h-[13px] accent-[var(--cyan)]"
|
||||||
className="accent-[var(--cyan)]"
|
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user