Merge pull request 'refactor: 현장분석/보고서 더미 데이터를 실데이터로 전환' (#196) from feature/dummy-to-real-data into develop
This commit is contained in:
커밋
51064212dc
@ -4,6 +4,13 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 변경
|
||||
- 현장분석: AI 파이프라인 더미 애니메이션 → analysisMap 기반 ON/OFF 실상태 표시
|
||||
- 현장분석: BD-09 변환 STANDBY → bd09OffsetM 실측 탐지 수 표시
|
||||
- 보고서: 수역별 허가업종 하드코딩 → ZONE_ALLOWED 상수 동적 참조
|
||||
- 보고서: 건의사항 월/최대 어구 선단 실데이터 연동
|
||||
- 보고서 버튼: 상단 헤더 → 현장분석 내부 닫기 버튼 좌측으로 이동
|
||||
|
||||
## [2026-03-25]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -110,6 +110,7 @@ interface Props {
|
||||
ships: Ship[];
|
||||
vesselAnalysis?: UseVesselAnalysisResult;
|
||||
onClose: () => void;
|
||||
onShowReport?: () => void;
|
||||
}
|
||||
|
||||
const PIPE_STEPS = [
|
||||
@ -124,14 +125,14 @@ const PIPE_STEPS = [
|
||||
|
||||
const ALERT_ORDER: Record<string, number> = { CRITICAL: 0, WATCH: 1, MONITOR: 2, NORMAL: 3 };
|
||||
|
||||
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowReport }: Props) {
|
||||
const emptyMap = useMemo(() => new Map<string, VesselAnalysisDto>(), []);
|
||||
const analysisMap = vesselAnalysis?.analysisMap ?? emptyMap;
|
||||
const [activeFilter, setActiveFilter] = useState('ALL');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [pipeStep, setPipeStep] = useState(0);
|
||||
// pipeStep 제거 — 파이프라인 상태는 analysisMap 존재 여부로 판단
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
// 중국 어선만 필터
|
||||
@ -189,9 +190,13 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
// 통계 — Python 분석 결과 기반
|
||||
const stats = useMemo(() => {
|
||||
let gpsAnomaly = 0;
|
||||
let bd09Detected = 0;
|
||||
for (const v of processed) {
|
||||
const dto = analysisMap.get(v.ship.mmsi);
|
||||
if (dto && dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++;
|
||||
if (dto) {
|
||||
if (dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++;
|
||||
if (dto.algorithms.gpsSpoofing.bd09OffsetM > 100) bd09Detected++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: processed.length,
|
||||
@ -199,6 +204,7 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
fishing: processed.filter(v => v.state === 'FISHING').length,
|
||||
aisLoss: processed.filter(v => v.state === 'AIS_LOSS' || v.state === 'UNKNOWN').length,
|
||||
gpsAnomaly,
|
||||
bd09Detected,
|
||||
clusters: new Set(processed.filter(v => v.cluster !== '—').map(v => v.cluster)).size,
|
||||
trawl: processed.filter(v => v.vtype === 'TRAWL').length,
|
||||
purse: processed.filter(v => v.vtype === 'PURSE').length,
|
||||
@ -231,12 +237,6 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// AI 파이프라인 애니메이션
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setPipeStep(s => s + 1), 1200);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
// 시계 tick
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setTick(s => s + 1), 1000);
|
||||
@ -349,6 +349,19 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
LIVE
|
||||
</span>
|
||||
<span style={{ color: C.ink2, fontSize: 10 }}>{new Date().toLocaleTimeString('ko-KR')}</span>
|
||||
{onShowReport && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowReport}
|
||||
style={{
|
||||
background: 'rgba(99,179,237,0.1)', border: '1px solid rgba(99,179,237,0.4)',
|
||||
color: '#63b3ed', padding: '4px 14px', cursor: 'pointer',
|
||||
fontSize: 11, borderRadius: 2, fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
📋 보고서
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@ -430,38 +443,46 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
|
||||
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, margin: '12px 0 8px', paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
|
||||
AI 파이프라인 상태
|
||||
<span style={{ float: 'right', color: C.green, fontSize: 8 }}>●</span>
|
||||
<span style={{ float: 'right', color: analysisMap.size > 0 ? C.green : C.red, fontSize: 8 }}>●</span>
|
||||
</div>
|
||||
|
||||
{PIPE_STEPS.map((step, idx) => {
|
||||
const isRunning = idx === pipeStep % PIPE_STEPS.length;
|
||||
{PIPE_STEPS.map((step) => {
|
||||
const connected = analysisMap.size > 0;
|
||||
return (
|
||||
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
|
||||
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
|
||||
<span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span>
|
||||
<span style={{
|
||||
fontSize: 8, padding: '1px 6px', borderRadius: 2,
|
||||
background: isRunning ? 'rgba(0,230,118,0.15)' : 'rgba(0,230,118,0.06)',
|
||||
border: `1px solid ${isRunning ? C.green : C.border}`,
|
||||
color: isRunning ? C.green : C.ink3,
|
||||
fontWeight: isRunning ? 700 : 400,
|
||||
background: connected ? 'rgba(0,230,118,0.1)' : 'rgba(255,82,82,0.1)',
|
||||
border: `1px solid ${connected ? C.green : C.red}`,
|
||||
color: connected ? C.green : C.red,
|
||||
fontWeight: 400,
|
||||
}}>
|
||||
{isRunning ? 'PROC' : 'OK'}
|
||||
{connected ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{[
|
||||
{ num: 'GPS', name: 'BD-09 변환', status: 'STANDBY', color: C.ink3 },
|
||||
{ num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3 },
|
||||
{
|
||||
num: 'GPS', name: 'BD-09 변환',
|
||||
status: stats.bd09Detected > 0 ? `${stats.bd09Detected}척 탐지` : 'CLEAR',
|
||||
color: stats.bd09Detected > 0 ? C.amber : C.green,
|
||||
active: stats.bd09Detected > 0,
|
||||
},
|
||||
{ num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3, active: false },
|
||||
].map(step => (
|
||||
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
|
||||
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
|
||||
<span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span>
|
||||
<span style={{
|
||||
fontSize: 8, padding: '1px 6px', borderRadius: 2,
|
||||
background: 'rgba(24,255,255,0.08)', border: `1px solid ${C.border}`, color: step.color,
|
||||
background: step.active ? 'rgba(255,215,64,0.12)' : 'rgba(24,255,255,0.08)',
|
||||
border: `1px solid ${step.active ? C.amber : C.border}`,
|
||||
color: step.color,
|
||||
fontWeight: step.active ? 700 : 400,
|
||||
}}>
|
||||
{step.status}
|
||||
</span>
|
||||
|
||||
@ -171,6 +171,13 @@ export const KoreaDashboard = ({
|
||||
const vesselAnalysis = useVesselAnalysis(true);
|
||||
const groupPolygons = useGroupPolygons(true);
|
||||
|
||||
const largestGearGroup = useMemo(() => {
|
||||
const gears = groupPolygons.allGroups.filter(g => g.groupType !== 'FLEET');
|
||||
if (gears.length === 0) return undefined;
|
||||
const max = gears.reduce((a, b) => a.memberCount > b.memberCount ? a : b);
|
||||
return { name: max.groupLabel, count: max.memberCount };
|
||||
}, [groupPolygons.allGroups]);
|
||||
|
||||
const koreaFiltersResult = useKoreaFilters(
|
||||
koreaData.ships,
|
||||
koreaData.visibleShips,
|
||||
@ -300,10 +307,6 @@ export const KoreaDashboard = ({
|
||||
onClick={() => setShowFieldAnalysis(v => !v)} title="현장분석">
|
||||
<span className="text-[11px]">📊</span>현장분석
|
||||
</button>
|
||||
<button type="button" className={`mode-btn ${showReport ? 'active' : ''}`}
|
||||
onClick={() => setShowReport(v => !v)} title="감시현황 보고서">
|
||||
<span className="text-[11px]">📋</span>보고서
|
||||
</button>
|
||||
<button type="button" className={`mode-btn ${showOpsGuide ? 'active' : ''}`}
|
||||
onClick={() => setShowOpsGuide(v => !v)} title="경비함정 작전 가이드">
|
||||
<span className="text-[11px]">⚓</span>작전가이드
|
||||
@ -327,10 +330,11 @@ export const KoreaDashboard = ({
|
||||
ships={koreaData.ships}
|
||||
vesselAnalysis={vesselAnalysis}
|
||||
onClose={() => setShowFieldAnalysis(false)}
|
||||
onShowReport={() => setShowReport(v => !v)}
|
||||
/>
|
||||
)}
|
||||
{showReport && (
|
||||
<ReportModal ships={koreaData.ships} onClose={() => setShowReport(false)} />
|
||||
<ReportModal ships={koreaData.ships} onClose={() => setShowReport(false)} largestGearGroup={largestGearGroup} />
|
||||
)}
|
||||
{showOpsGuide && (
|
||||
<OpsGuideModal
|
||||
|
||||
@ -1,12 +1,42 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import type { Ship } from '../../types';
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
import { aggregateFishingStats, GEAR_LABELS, classifyFishingZone } from '../../utils/fishingAnalysis';
|
||||
import { aggregateFishingStats, GEAR_LABELS, classifyFishingZone, ZONE_ALLOWED } from '../../utils/fishingAnalysis';
|
||||
import type { FishingGearType } from '../../utils/fishingAnalysis';
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
onClose: () => void;
|
||||
largestGearGroup?: { name: string; count: number };
|
||||
}
|
||||
|
||||
const ALL_GEAR_TYPES = ['PT', 'OT', 'GN', 'PS', 'FC'];
|
||||
|
||||
const ZONE_LABELS: Record<string, string> = {
|
||||
ZONE_I: '수역 I (동해)',
|
||||
ZONE_II: '수역 II (남해)',
|
||||
ZONE_III: '수역 III (서남해)',
|
||||
ZONE_IV: '수역 IV (서해)',
|
||||
};
|
||||
|
||||
const ZONE_EXTRA_NOTES: Record<string, string> = {
|
||||
ZONE_III: '이어도 해역',
|
||||
};
|
||||
|
||||
function zoneAllowedText(zone: string): string {
|
||||
const allowed = ZONE_ALLOWED[zone];
|
||||
if (!allowed || allowed.length === 0) return '-';
|
||||
if (allowed.length >= ALL_GEAR_TYPES.length) return '전 업종';
|
||||
return allowed.join(', ') + (allowed.length <= 2 ? '만' : '');
|
||||
}
|
||||
|
||||
function zoneViolationText(zone: string): string {
|
||||
const allowed = ZONE_ALLOWED[zone];
|
||||
if (!allowed) return '-';
|
||||
const violations = ALL_GEAR_TYPES.filter(t => !allowed.includes(t));
|
||||
if (violations.length === 0) return ZONE_EXTRA_NOTES[zone] || '-';
|
||||
const extra = ZONE_EXTRA_NOTES[zone] ? ` (${ZONE_EXTRA_NOTES[zone]})` : '';
|
||||
return `${violations.join('/')} 발견 시 위반${extra}`;
|
||||
}
|
||||
|
||||
function now() {
|
||||
@ -14,7 +44,7 @@ function now() {
|
||||
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ReportModal({ ships, onClose }: Props) {
|
||||
export function ReportModal({ ships, onClose, largestGearGroup }: Props) {
|
||||
const reportRef = useRef<HTMLDivElement>(null);
|
||||
const timestamp = useMemo(() => now(), []);
|
||||
|
||||
@ -186,13 +216,17 @@ export function ReportModal({ ships, onClose }: Props) {
|
||||
<h2 style={h2Style}>4. 특정어업수역별 분포</h2>
|
||||
<table style={tableStyle}>
|
||||
<thead><tr style={{ background: '#1e293b' }}>
|
||||
<th style={thStyle}>수역</th><th style={thStyle}>어선 수</th><th style={thStyle}>허가 업종 (3월)</th><th style={thStyle}>비고</th>
|
||||
<th style={thStyle}>수역</th><th style={thStyle}>어선 수</th><th style={thStyle}>허가 업종 ({new Date().getMonth() + 1}월)</th><th style={thStyle}>비고</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td style={tdStyle}>수역 I (동해)</td><td style={tdBold}>{stats.zoneStats.ZONE_I}</td><td style={tdDim}>PS, FC만</td><td style={tdDim}>PT/OT/GN 발견 시 위반</td></tr>
|
||||
<tr><td style={tdStyle}>수역 II (남해)</td><td style={tdBold}>{stats.zoneStats.ZONE_II}</td><td style={tdDim}>전 업종</td><td style={tdDim}>-</td></tr>
|
||||
<tr><td style={tdStyle}>수역 III (서남해)</td><td style={tdBold}>{stats.zoneStats.ZONE_III}</td><td style={tdDim}>전 업종</td><td style={tdDim}>이어도 해역</td></tr>
|
||||
<tr><td style={tdStyle}>수역 IV (서해)</td><td style={tdBold}>{stats.zoneStats.ZONE_IV}</td><td style={tdDim}>GN, PS, FC</td><td style={tdDim}>PT/OT 발견 시 위반</td></tr>
|
||||
{(['ZONE_I', 'ZONE_II', 'ZONE_III', 'ZONE_IV'] as const).map(zone => (
|
||||
<tr key={zone}>
|
||||
<td style={tdStyle}>{ZONE_LABELS[zone]}</td>
|
||||
<td style={tdBold}>{stats.zoneStats[zone]}</td>
|
||||
<td style={tdDim}>{zoneAllowedText(zone)}</td>
|
||||
<td style={tdDim}>{zoneViolationText(zone)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr style={{ background: 'rgba(239,68,68,0.1)' }}><td style={tdStyle}>수역 외</td><td style={{ ...tdBold, color: '#ef4444' }}>{stats.zoneStats.OUTSIDE}</td><td style={tdDim}>-</td><td style={{ ...tdDim, color: '#ef4444' }}>비허가 구역</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -226,11 +260,15 @@ export function ReportModal({ ships, onClose }: Props) {
|
||||
{/* 7. 건의사항 */}
|
||||
<h2 style={h2Style}>7. 건의사항</h2>
|
||||
<div style={{ fontSize: 10, color: '#94a3b8', paddingLeft: 12 }}>
|
||||
<p>1. 현재 3월은 전 업종 조업 가능 기간으로, <strong style={{ color: '#f59e0b' }}>수역 이탈 및 본선-부속선 분리</strong> 중심 감시 권고</p>
|
||||
<p>1. 현재 {new Date().getMonth() + 1}월은 전 업종 조업 가능 기간으로, <strong style={{ color: '#f59e0b' }}>수역 이탈 및 본선-부속선 분리</strong> 중심 감시 권고</p>
|
||||
<p>2. 다크베셀 의심 {stats.darkSuspect.length}척에 대해 <strong style={{ color: '#ef4444' }}>SAR 위성 집중 탐색</strong> 요청</p>
|
||||
<p>3. 수역 외 어선 {stats.zoneStats.OUTSIDE}척에 대해 <strong style={{ color: '#ef4444' }}>즉시 현장 확인</strong> 필요</p>
|
||||
<p>4. 4/16 저인망 휴어기 진입 대비 <strong>감시 강화 계획 수립</strong> 권고</p>
|
||||
<p>5. 宁波海裕 위망 선단 16척 <strong>그룹 위치 상시 추적</strong> 유지</p>
|
||||
{largestGearGroup ? (
|
||||
<p>5. {largestGearGroup.name} 선단 {largestGearGroup.count}척 <strong>그룹 위치 상시 추적</strong> 유지</p>
|
||||
) : (
|
||||
<p>5. 대형 어구 선단 <strong>그룹 위치 상시 추적</strong> 유지</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@ -56,7 +56,7 @@ export interface FishingZoneInfo {
|
||||
}
|
||||
|
||||
/** 수역별 허가 업종 */
|
||||
const ZONE_ALLOWED: Record<string, string[]> = {
|
||||
export const ZONE_ALLOWED: Record<string, string[]> = {
|
||||
ZONE_I: ['PS', 'FC'],
|
||||
ZONE_II: ['PT', 'OT', 'GN', 'PS', 'FC'],
|
||||
ZONE_III: ['PT', 'OT', 'GN', 'PS', 'FC'],
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user