KCG AI 기반 불법조업 탐지·차단 플랫폼 프론트엔드. React 19 + TypeScript 5.9 + Vite 8 + MapLibre + deck.gl + Zustand + Tailwind CSS. SFR 20개 전체 UI 구현 완료, 백엔드 연동 대기. - npm + Nexus 프록시 레지스트리 설정 - 팀 워크플로우 v1.6.1 부트스트랩 파일 배치 - .githooks (commit-msg, post-checkout) - package.json name: kcg-ai-monitoring v0.1.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
394 lines
20 KiB
TypeScript
394 lines
20 KiB
TypeScript
import { useState, useRef, useCallback } from 'react';
|
|
import { Card, CardContent } from '@shared/components/ui/card';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
import {
|
|
Search, ChevronDown, ChevronUp, ChevronRight, Plus, X,
|
|
Ship, AlertTriangle, Radar, Anchor, MapPin, Printer,
|
|
Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain
|
|
} from 'lucide-react';
|
|
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map';
|
|
import type { MarkerData } from '@lib/map';
|
|
|
|
// TODO: 향후 store 통합 시 교체 — VesselDetail의 VesselTrack 형상(callSign, source, detail 등)이
|
|
// useVesselStore().vessels(VesselData)와 구조가 달라 현재는 인라인 데이터 유지
|
|
// ─── 선박 데이터 ──────────────────────
|
|
interface VesselTrack {
|
|
id: string;
|
|
mmsi: string;
|
|
callSign: string;
|
|
source: string;
|
|
name: string;
|
|
type: string;
|
|
country: string;
|
|
detail: Record<string, string>;
|
|
}
|
|
|
|
const VESSELS: VesselTrack[] = [
|
|
{
|
|
id: '1', mmsi: '440162980', callSign: '122@', source: 'AIS',
|
|
name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)',
|
|
detail: {
|
|
'청코드': '부산', '호출부호': '951554', '입항횟수': '006', '전송구분': '최종',
|
|
'선명': '태평양호', '선박종류': '어선', '총톤수': '30', '국제톤수': '30',
|
|
'입항일시': '2023-03-28 16:00', '계선장소': '기타 남항 사설조선소',
|
|
'전출항지': '2023-03-28 16:00', '전출항지항구명': '김천', '위험물톤수': '-',
|
|
'외내항구분': '내항', '입항수리일자': '2023-03-24',
|
|
'한국인선원수': '5', '외국인선원수': '9', '예선': 'N', '도선': 'N',
|
|
},
|
|
},
|
|
{
|
|
id: '2', mmsi: '440162923', callSign: '122@', source: 'AIS',
|
|
name: 'ZZ', type: 'V-Pass', country: 'Korea(Republic of)',
|
|
detail: {
|
|
'청코드': '인천', '호출부호': '862331', '입항횟수': '012', '전송구분': '최종',
|
|
'선명': '금강호', '선박종류': '어선', '총톤수': '45', '국제톤수': '45',
|
|
'입항일시': '2023-04-15 09:00', '계선장소': '인천항 제2부두',
|
|
'전출항지': '2023-04-15 09:00', '전출항지항구명': '인천', '위험물톤수': '-',
|
|
'외내항구분': '내항', '입항수리일자': '2023-04-10',
|
|
'한국인선원수': '3', '외국인선원수': '7', '예선': 'N', '도선': 'N',
|
|
},
|
|
},
|
|
];
|
|
|
|
// ─── 특이운항 / 비허가 선박 ──────────────
|
|
const ALERT_VESSELS = [
|
|
{ name: '제303 대양호', highlight: true },
|
|
{ name: '제609 한일호', highlight: false },
|
|
{ name: '한진아일랜드 고속훼리', highlight: false },
|
|
];
|
|
|
|
// ─── AI 조업 분석 데이터 ─────────────────
|
|
interface FishingAnalysis {
|
|
no: number;
|
|
mmsi: string;
|
|
name: string;
|
|
eezPermit: '허가' | '무허가';
|
|
vesselType: '어선' | '어구';
|
|
gearType: string;
|
|
gearIcon: string;
|
|
}
|
|
|
|
const FISHING_ANALYSIS: FishingAnalysis[] = [
|
|
{ no: 1, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '무허가', vesselType: '어구', gearType: '쌍끌이', gearIcon: '🚢' },
|
|
{ no: 2, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '범장망', gearIcon: '🚢' },
|
|
{ no: 3, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '-', gearIcon: '' },
|
|
{ no: 4, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '-', gearIcon: '' },
|
|
{ no: 5, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '-', gearIcon: '' },
|
|
];
|
|
|
|
const GEAR_FILTERS = ['외끌이', '쌍끌이', '트롤', '범장망', '형망', '채낚기', '통망'];
|
|
|
|
// ─── 지도 마커 ────────────────────────
|
|
const MAP_MARKERS = [
|
|
{ id: 'm1', x: 72, y: 38, label: '현재선박명', sensors: ['E', 'A', 'V'] },
|
|
{ id: 'm2', x: 65, y: 43, label: '현재선박명', sensors: ['V', 'B', 'A'] },
|
|
{ id: 'm3', x: 73, y: 49, label: '현재선박명', sensors: ['A', 'V', 'E'] },
|
|
];
|
|
const VTS_MARKERS = [{ id: 'vts1', x: 52, y: 63, label: '태안연안', sub: 'VTS 신호수신 선박명' }];
|
|
const PATROL_MARKERS = [
|
|
{ id: 'p1', x: 62, y: 63, label: 'E204', sub: '함정레이더 신호수신 선박명' },
|
|
{ id: 'p2', x: 80, y: 70, label: 'E204', sub: '함정레이더 신호수신 선박명' },
|
|
];
|
|
const CLUSTERS = [
|
|
{ x: 58, y: 22, n: 10 }, { x: 75, y: 30, n: 5 }, { x: 52, y: 55, n: 5 }, { x: 35, y: 68, n: 5 },
|
|
];
|
|
|
|
const RIGHT_TOOLS = [
|
|
{ icon: Crosshair, label: '구역설정' }, { icon: Ruler, label: '거리' },
|
|
{ icon: CircleDot, label: '면적' }, { icon: Clock, label: '거리환' },
|
|
{ icon: Printer, label: '인쇄' }, { icon: Camera, label: '스냅샷' },
|
|
];
|
|
|
|
// ─── 메인 컴포넌트 ────────────────────
|
|
|
|
export function VesselDetail() {
|
|
const [expandedId, setExpandedId] = useState<string | null>('2');
|
|
const [startDate, setStartDate] = useState('2023-08-20 11:30:02');
|
|
const [endDate, setEndDate] = useState('2023-08-20 11:30:02');
|
|
const [shipId, setShipId] = useState('');
|
|
const [showAiPanel, setShowAiPanel] = useState(false);
|
|
const [gearChecks, setGearChecks] = useState<Record<string, boolean>>({ '쌍끌이': true, '범장망': true });
|
|
const mapRef = useRef<MapHandle>(null);
|
|
|
|
const buildLayers = useCallback(() => [
|
|
...STATIC_LAYERS,
|
|
|
|
// 관할해역 구역
|
|
createZoneLayer('jurisdiction', JURISDICTION_AREAS.map(a => ({
|
|
name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000,
|
|
})), 80000, 0.05),
|
|
|
|
// 등심선
|
|
...DEPTH_CONTOURS.map((contour, i) =>
|
|
createPolylineLayer(`depth-${i}`, contour.points as [number, number][], {
|
|
color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4],
|
|
})
|
|
),
|
|
|
|
// 선박 마커
|
|
createMarkerLayer('vessels', MAP_MARKERS.map((m): MarkerData => {
|
|
const lat = 34.2 + Math.random() * 2;
|
|
const lng = 125.5 + Math.random() * 3;
|
|
return { lat, lng, color: '#3b82f6', radius: 800, label: m.label };
|
|
})),
|
|
|
|
// VTS 마커
|
|
createMarkerLayer('vts', VTS_MARKERS.map((m): MarkerData => ({
|
|
lat: 34.0, lng: 126.2, color: '#eab308', radius: 800, label: m.label,
|
|
}))),
|
|
|
|
// 함정 마커
|
|
createMarkerLayer('patrols', PATROL_MARKERS.map((m): MarkerData => ({
|
|
lat: 33.5 + Math.random(), lng: 127.0 + Math.random(), color: '#a855f7', radius: 800, label: m.label,
|
|
}))),
|
|
|
|
// 클러스터
|
|
createMarkerLayer('clusters', CLUSTERS.map((c, i): MarkerData => ({
|
|
lat: 33.0 + i * 0.8, lng: 125.5 + i * 0.5, color: '#6b7280', radius: 2400, label: `${c.n}척`,
|
|
}))),
|
|
|
|
// 선박충돌 알림
|
|
createMarkerLayer('alerts', [{
|
|
lat: 33.8, lng: 127.5, color: '#ef4444', radius: 1400, label: '선박충돌',
|
|
}]),
|
|
], []);
|
|
|
|
useMapLayers(mapRef, buildLayers, []);
|
|
|
|
const toggleGear = (g: string) => setGearChecks((p) => ({ ...p, [g]: !p[g] }));
|
|
|
|
return (
|
|
<div className="flex h-[calc(100vh-7.5rem)] gap-0 -m-4">
|
|
|
|
{/* ── 좌측: 항적조회 패널 ── */}
|
|
<div className="w-[370px] shrink-0 bg-card border-r border-border flex flex-col overflow-hidden">
|
|
{/* 헤더: 검색 조건 */}
|
|
<div className="p-3 border-b border-border space-y-2">
|
|
<h2 className="text-sm font-bold text-heading">항적조회</h2>
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[9px] text-hint w-14 shrink-0">시작/종료</span>
|
|
<input value={startDate} onChange={(e) => setStartDate(e.target.value)}
|
|
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50" />
|
|
<span className="text-hint text-[10px]">~</span>
|
|
<input value={endDate} onChange={(e) => setEndDate(e.target.value)}
|
|
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50" />
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[9px] text-hint w-14 shrink-0">조회간격</span>
|
|
<select className="bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none w-16">
|
|
<option>전체</option><option>1분</option><option>5분</option><option>10분</option><option>30분</option>
|
|
</select>
|
|
<span className="text-[9px] text-hint ml-2 shrink-0">선박ID</span>
|
|
<input value={shipId} onChange={(e) => setShipId(e.target.value)}
|
|
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none" />
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<button className="flex items-center gap-1 text-[10px] text-blue-400 hover:text-blue-300">
|
|
<Plus className="w-3 h-3" />선박추가
|
|
</button>
|
|
<button className="flex items-center gap-1.5 bg-secondary border border-slate-700/50 rounded px-3 py-1 text-[10px] text-label hover:bg-switch-background transition-colors">
|
|
검색 <Search className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 선박 카드 */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{VESSELS.map((v) => {
|
|
const isOpen = expandedId === v.id;
|
|
return (
|
|
<div key={v.id} className="border-b border-border">
|
|
<div className="flex items-center gap-2 px-3 py-2.5 hover:bg-surface-overlay cursor-pointer" onClick={() => setExpandedId(isOpen ? null : v.id)}>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-[9px] text-hint">
|
|
ID | <span className="text-label">{v.mmsi}</span>
|
|
<span className="ml-2">호출부호 | <span className="text-label">{v.callSign}</span></span>
|
|
<span className="ml-2">출처 | <span className="text-label">{v.source}</span></span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 mt-0.5">
|
|
<span className="text-[11px] font-bold text-heading">{v.name}</span>
|
|
<Badge className={`text-[7px] px-1 py-0 border-0 ${v.type === 'Fishing' ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400'}`}>{v.type}</Badge>
|
|
</div>
|
|
<div className="text-[8px] text-hint mt-0.5 flex items-center gap-1">🇰🇷 {v.country}</div>
|
|
</div>
|
|
{isOpen ? <ChevronUp className="w-4 h-4 text-blue-400" /> : <ChevronDown className="w-4 h-4 text-hint" />}
|
|
</div>
|
|
|
|
{isOpen && (
|
|
<div className="px-3 pb-3">
|
|
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
|
|
{Object.entries(v.detail).map(([k, val], i) => (
|
|
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
|
|
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
|
|
<span className="flex-1 px-2.5 py-1.5 text-label">{val}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 중앙: 지도 ── */}
|
|
<div className="flex-1 relative bg-card/40 overflow-hidden">
|
|
|
|
{/* 상단 패널: 특이운항 + 비허가/재제선박 */}
|
|
<div className="absolute top-3 left-3 z-10 flex gap-2">
|
|
{(['특이운항', '비허가/재제선박'] as const).map((title) => (
|
|
<div key={title} className="bg-card/95 backdrop-blur-sm rounded-lg border border-border w-52">
|
|
<div className="px-3 py-1.5 border-b border-border flex items-center justify-between">
|
|
<span className="text-[10px] font-bold text-heading">{title}</span>
|
|
<ChevronDown className="w-3 h-3 text-hint" />
|
|
</div>
|
|
{ALERT_VESSELS.map((v, i) => (
|
|
<button key={i} className={`w-full flex items-center justify-between px-3 py-1.5 text-[9px] transition-colors ${v.highlight ? 'bg-red-600/80 text-heading' : 'text-label hover:bg-surface-overlay'}`}>
|
|
{v.name}<ChevronRight className="w-3 h-3 opacity-50" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* AI 조업 분석 패널 (토글) */}
|
|
<button
|
|
onClick={() => setShowAiPanel(!showAiPanel)}
|
|
className="absolute top-3 right-14 z-20 flex items-center gap-1.5 bg-blue-600/90 backdrop-blur-sm text-heading rounded-lg px-3 py-1.5 text-[10px] font-bold hover:bg-blue-500 transition-colors shadow-lg"
|
|
>
|
|
<Brain className="w-3.5 h-3.5" />AI 조업 분석
|
|
</button>
|
|
|
|
{showAiPanel && (
|
|
<div className="absolute top-12 right-14 z-20 w-[480px] bg-input-background/98 backdrop-blur-md rounded-xl border border-blue-500/30 shadow-2xl shadow-blue-900/20 overflow-hidden">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between px-4 py-2.5 bg-surface-overlay border-b border-border">
|
|
<div className="flex items-center gap-2">
|
|
<Brain className="w-4 h-4 text-blue-400" />
|
|
<span className="text-[11px] font-bold text-heading">AI 조업 분석</span>
|
|
</div>
|
|
<button onClick={() => setShowAiPanel(false)} className="text-hint hover:text-heading"><X className="w-4 h-4" /></button>
|
|
</div>
|
|
|
|
{/* 선택선박 + 조업식별 필터 */}
|
|
<div className="px-4 py-2.5 border-b border-border">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="flex items-center gap-1.5 text-[10px]">
|
|
<Anchor className="w-3 h-3 text-muted-foreground" />
|
|
<span className="text-muted-foreground">선택선박</span>
|
|
<span className="text-heading font-bold text-sm">50</span>
|
|
<span className="text-hint">척</span>
|
|
</div>
|
|
<button className="ml-auto bg-blue-600 text-heading rounded-lg px-4 py-1.5 text-[10px] font-bold flex items-center gap-1 hover:bg-blue-500 transition-colors">
|
|
<Search className="w-3 h-3" />검색
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
<span className="text-[9px] text-hint mr-1 flex items-center gap-1"><Radar className="w-3 h-3" />조업식별</span>
|
|
{GEAR_FILTERS.map((g) => (
|
|
<label key={g} className="flex items-center gap-1 cursor-pointer">
|
|
<input
|
|
type="checkbox" checked={!!gearChecks[g]}
|
|
onChange={() => toggleGear(g)}
|
|
className="w-3 h-3 rounded border-slate-600 bg-secondary text-blue-500 focus:ring-0 focus:ring-offset-0"
|
|
/>
|
|
<span className="text-[9px] text-label">{g}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 테이블 헤더 */}
|
|
<div className="grid grid-cols-[32px_1fr_70px_70px_90px] gap-1 px-4 py-1.5 text-[9px] text-hint font-medium border-b border-border bg-surface-overlay">
|
|
<span>구분</span>
|
|
<span>선박ID/선박명</span>
|
|
<span>EEZ허가</span>
|
|
<span>어선/어구</span>
|
|
<span>조업식별</span>
|
|
</div>
|
|
|
|
{/* 테이블 행 */}
|
|
<div className="max-h-[300px] overflow-y-auto">
|
|
{FISHING_ANALYSIS.map((row) => (
|
|
<div
|
|
key={row.no}
|
|
className={`grid grid-cols-[32px_1fr_70px_70px_90px] gap-1 px-4 py-2.5 items-center border-b border-border hover:bg-surface-overlay cursor-pointer transition-colors ${row.no === 1 ? 'bg-surface-overlay' : ''}`}
|
|
>
|
|
<span className="text-[10px] text-muted-foreground">{row.no}</span>
|
|
<div>
|
|
<div className="text-[8px] text-hint">ID | {row.mmsi}</div>
|
|
<div className="text-[11px] font-bold text-heading">{row.name}</div>
|
|
</div>
|
|
<span className={`text-[10px] font-bold ${row.eezPermit === '무허가' ? 'text-red-400' : 'text-green-400'}`}>
|
|
{row.eezPermit}
|
|
</span>
|
|
<span className="text-[10px] text-label">{row.vesselType}</span>
|
|
<div className="flex items-center gap-1">
|
|
{row.gearType !== '-' && (
|
|
<Badge className={`text-[8px] px-1.5 py-0.5 border-0 ${
|
|
row.gearType === '쌍끌이' ? 'bg-orange-500/20 text-orange-400'
|
|
: row.gearType === '범장망' ? 'bg-purple-500/20 text-purple-400'
|
|
: 'bg-muted text-muted-foreground'
|
|
}`}>
|
|
{row.gearIcon && <span className="mr-0.5">{row.gearIcon}</span>}
|
|
{row.gearType}
|
|
</Badge>
|
|
)}
|
|
{row.gearType === '-' && <span className="text-[10px] text-hint">-</span>}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* MapLibre GL + deck.gl 지도 */}
|
|
<BaseMap
|
|
ref={mapRef}
|
|
center={[34.5, 126.5]}
|
|
zoom={7}
|
|
height="100%"
|
|
/>
|
|
|
|
{/* 하단 좌표 바 */}
|
|
<div className="absolute bottom-0 left-0 right-0 h-6 bg-background/90 backdrop-blur-sm border-t border-border flex items-center justify-center gap-4 px-4 z-[1000]">
|
|
<span className="flex items-center gap-1 text-[8px]">
|
|
<MapPin className="w-2.5 h-2.5 text-green-400" />
|
|
<span className="text-hint">위도</span>
|
|
<span className="text-green-400 font-mono font-bold">34.5000</span>
|
|
</span>
|
|
<span className="flex items-center gap-1 text-[8px]">
|
|
<MapPin className="w-2.5 h-2.5 text-green-400" />
|
|
<span className="text-hint">경도</span>
|
|
<span className="text-green-400 font-mono font-bold">126.5000</span>
|
|
</span>
|
|
<span className="text-[8px]">
|
|
<span className="text-blue-400 font-bold">UTC</span>
|
|
<span className="text-label font-mono ml-1">2023-07-10(월) 12:32:45</span>
|
|
</span>
|
|
<span className="ml-auto text-[7px] text-hint">8,531 | 0 25 50NM</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 우측 도구바 ── */}
|
|
<div className="w-10 bg-background border-l border-border flex flex-col items-center py-2 gap-0.5 shrink-0">
|
|
{RIGHT_TOOLS.map((t) => (
|
|
<button key={t.label} className="flex flex-col items-center gap-0.5 py-1.5 px-1 rounded hover:bg-surface-overlay text-hint hover:text-label transition-colors" title={t.label}>
|
|
<t.icon className="w-3.5 h-3.5" /><span className="text-[6px]">{t.label}</span>
|
|
</button>
|
|
))}
|
|
<div className="flex-1" />
|
|
<div className="flex flex-col border border-border rounded-lg overflow-hidden">
|
|
<button className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">+</button>
|
|
<div className="h-px bg-white/[0.06]" />
|
|
<button className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">-</button>
|
|
</div>
|
|
<button className="mt-1 flex flex-col items-center py-1 text-hint hover:text-label"><LayoutGrid className="w-3.5 h-3.5" /><span className="text-[6px]">범례</span></button>
|
|
<button className="flex flex-col items-center py-1 text-hint hover:text-label"><div className="w-3.5 h-3.5 border border-slate-500 rounded-sm" /><span className="text-[6px]">미니맵</span></button>
|
|
<button className="flex flex-col items-center py-1 bg-blue-600/20 text-blue-400 rounded"><Radar className="w-3.5 h-3.5" /><span className="text-[6px]">AI모드</span></button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|