Merge pull request 'refactor: 현장분석/보고서 더미 데이터를 실데이터로 전환' (#196) from feature/dummy-to-real-data into develop

This commit is contained in:
htlee 2026-03-25 10:45:23 +09:00
커밋 51064212dc
5개의 변경된 파일105개의 추가작업 그리고 35개의 파일을 삭제

파일 보기

@ -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'],