- prediction: dark_vessel 의심 점수화(8패턴 0~100), transshipment 베테랑 재설계 - prediction: vessel_store/scheduler/config 개선, monitoring_zones 데이터 추가 - prediction: signal_api 신규, diagnostic-snapshot 스크립트 추가 - frontend: 지도 레이어 구조 정리 (BaseMap, useMapLayers, static layers) - frontend: NoticeManagement CRUD 권한 가드 추가 (admin:notices C/U/D) - frontend: EventList CRUD 권한 가드 추가 (enforcement:event-list U, enforcement:enforcement-history C) - frontend: 지도 페이지 6개 + Dashboard 등 4개 페이지 소폭 개선 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
234 lines
11 KiB
TypeScript
234 lines
11 KiB
TypeScript
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { BaseMap, createStaticLayers, createMarkerLayer, createPolylineLayer, createZoneLayer, useMapLayers, type MapHandle } from '@lib/map';
|
|
import { Card, CardContent } from '@shared/components/ui/card';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
import { Button } from '@shared/components/ui/button';
|
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
|
import { Users, Ship, Target, BarChart3, Play, CheckCircle, AlertTriangle, Layers, RefreshCw } from 'lucide-react';
|
|
import { getStatusIntent } from '@shared/constants/statusIntent';
|
|
import { usePatrolStore } from '@stores/patrolStore';
|
|
|
|
/* SFR-08: AI 경비함정 다함정 협력형 경로 최적화 서비스 */
|
|
|
|
const FLEET_SHIP_IDS = ['P-3001', 'P-3005', 'P-3009', 'P-5001', 'P-1503'];
|
|
const FLEET_COLORS: Record<string, string> = {
|
|
'P-3001': '#ef4444',
|
|
'P-3005': '#f97316',
|
|
'P-3009': '#eab308',
|
|
'P-5001': '#22c55e',
|
|
'P-1503': '#64748b',
|
|
};
|
|
|
|
|
|
export function FleetOptimization() {
|
|
const { t } = useTranslation('patrol');
|
|
const { ships, coverage: COVERAGE, fleetRoutes: FLEET_ROUTES, load } = usePatrolStore();
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const mapRef = useRef<MapHandle>(null);
|
|
const [simRunning, setSimRunning] = useState(false);
|
|
const [approved, setApproved] = useState(false);
|
|
|
|
const FLEET = useMemo(
|
|
() =>
|
|
ships
|
|
.filter((s) => FLEET_SHIP_IDS.includes(s.id))
|
|
.map((s) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
zone: s.zone ?? '-',
|
|
status: s.status === '정비중' ? '정비중' : ['추적중', '검문중', '초계중'].includes(s.status) ? '출동중' : '가용',
|
|
speed: s.speed > 0 ? `${s.speed}kt` : '-',
|
|
fuel: s.fuel,
|
|
eta: s.status === '정비중' ? '-' : ['추적중', '검문중', '초계중'].includes(s.status) ? '2h' : '즉시',
|
|
lat: s.lat,
|
|
lng: s.lng,
|
|
color: FLEET_COLORS[s.id] ?? '#64748b',
|
|
})),
|
|
[ships],
|
|
);
|
|
|
|
const buildLayers = useCallback(() => {
|
|
// 커버리지 영역 (최적화 후)
|
|
const coverageZones = COVERAGE.map((c) => ({
|
|
name: c.zone,
|
|
lat: c.lat,
|
|
lng: c.lng,
|
|
color: c.optimized > 90 ? '#06b6d4' : c.optimized > 70 ? '#3b82f6' : '#64748b',
|
|
radiusM: c.radius,
|
|
}));
|
|
|
|
// 커버리지 라벨 마커
|
|
const coverageLabels = COVERAGE.map((c) => ({
|
|
lat: c.lat,
|
|
lng: c.lng,
|
|
color: c.optimized > 90 ? '#06b6d4' : c.optimized > 70 ? '#3b82f6' : '#64748b',
|
|
radius: 500,
|
|
label: `${c.zone} ${c.optimized}%`,
|
|
}));
|
|
|
|
// 함정별 순찰 경로
|
|
const routeLayers = FLEET
|
|
.filter((f) => FLEET_ROUTES[f.id] && f.status !== '정비중')
|
|
.map((f) =>
|
|
createPolylineLayer(`route-${f.id}`, FLEET_ROUTES[f.id], {
|
|
color: f.color,
|
|
width: 2.5,
|
|
opacity: 0.7,
|
|
dashArray: f.status === '출동중' ? [6, 4] : undefined,
|
|
}),
|
|
);
|
|
|
|
// 함정 마커
|
|
const fleetMarkers = FLEET.map((f) => ({
|
|
lat: f.lat,
|
|
lng: f.lng,
|
|
color: f.color,
|
|
radius: f.status !== '정비중' ? 1200 : 600,
|
|
}));
|
|
|
|
return [
|
|
...createStaticLayers(),
|
|
createZoneLayer('coverage', coverageZones, 30000, 0.12),
|
|
createMarkerLayer('coverage-labels', coverageLabels),
|
|
...routeLayers,
|
|
createMarkerLayer('fleet-markers', fleetMarkers),
|
|
];
|
|
}, [FLEET, COVERAGE, FLEET_ROUTES]);
|
|
useMapLayers(mapRef, buildLayers, [ships, COVERAGE, FLEET_ROUTES]);
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
icon={Users}
|
|
iconColor="text-purple-400"
|
|
title={t('fleetOptimization.title')}
|
|
description={t('fleetOptimization.desc')}
|
|
demo
|
|
actions={
|
|
<>
|
|
<Button variant="primary" size="sm" onClick={() => setSimRunning(true)} icon={<Play className="w-3 h-3" />}>
|
|
시뮬레이션
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => setApproved(true)}
|
|
disabled={!simRunning}
|
|
icon={<CheckCircle className="w-3 h-3" />}
|
|
className="bg-green-600 hover:bg-green-500 border-green-700"
|
|
>
|
|
최종 승인
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{/* KPI */}
|
|
<div className="flex gap-2">
|
|
{[{ l: '투입 가능', v: `${FLEET.filter(f => f.status === '가용').length}척`, c: 'text-green-400', i: Ship },
|
|
{ l: '커버리지(현재)', v: '52%', c: 'text-yellow-400', i: Target },
|
|
{ l: '커버리지(최적화)', v: '88%', c: 'text-cyan-400', i: Layers },
|
|
{ l: '효율 개선', v: '+36%p', c: 'text-purple-400', i: BarChart3 },
|
|
].map(k => (
|
|
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
|
<k.i className={`w-4 h-4 ${k.c}`} /><span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{/* 함정 목록 */}
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="text-[12px] font-bold text-heading mb-3">투입 가능 함정</div>
|
|
<div className="space-y-2">
|
|
{FLEET.map(f => (
|
|
<div key={f.id} className={`px-3 py-2 rounded-lg ${f.status === '가용' ? 'bg-surface-overlay' : 'bg-surface-overlay opacity-50'}`}>
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: f.color }} />
|
|
<span className="text-[11px] font-bold text-heading">{f.name}</span>
|
|
</div>
|
|
<Badge intent={f.status === '출동중' ? 'warning' : getStatusIntent(f.status)} size="xs">{f.status}</Badge>
|
|
</div>
|
|
<div className="flex gap-3 mt-1 text-[9px] text-hint">
|
|
<span>구역: {f.zone}</span><span>속력: {f.speed}</span><span>연료: {f.fuel}%</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 커버리지 비교 */}
|
|
<Card className="col-span-2 bg-surface-raised border-border">
|
|
<CardContent className="p-4">
|
|
<div className="text-[12px] font-bold text-heading mb-3">해역 커버리지 비교 (현재 vs AI 최적화)</div>
|
|
<div className="space-y-3">
|
|
{COVERAGE.map(c => (
|
|
<div key={c.zone} className="space-y-1">
|
|
<div className="flex justify-between text-[10px]">
|
|
<span className="text-muted-foreground">{c.zone}</span>
|
|
<span className="text-hint">{c.ships > 0 ? `${c.ships}척 배치` : '미배치'}</span>
|
|
</div>
|
|
<div className="flex gap-2 items-center">
|
|
<span className="text-[9px] text-hint w-8">현재</span>
|
|
<div className="flex-1 h-2 bg-switch-background/60 rounded-full overflow-hidden">
|
|
<div className="h-full bg-yellow-500 rounded-full" style={{ width: `${c.current}%` }} />
|
|
</div>
|
|
<span className="text-[9px] text-yellow-400 w-8 text-right">{c.current}%</span>
|
|
</div>
|
|
<div className="flex gap-2 items-center">
|
|
<span className="text-[9px] text-hint w-8">최적</span>
|
|
<div className="flex-1 h-2 bg-switch-background/60 rounded-full overflow-hidden">
|
|
<div className="h-full bg-cyan-500 rounded-full" style={{ width: `${c.optimized}%` }} />
|
|
</div>
|
|
<span className="text-[9px] text-cyan-400 w-8 text-right">{c.optimized}%</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{approved && (
|
|
<div className="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-lg flex items-center gap-2">
|
|
<CheckCircle className="w-4 h-4 text-green-400" />
|
|
<span className="text-[11px] text-green-400 font-bold">최종 적용 승인 완료 (Human in the loop)</span>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 해역 커버리지 지도 */}
|
|
<Card>
|
|
<CardContent className="p-0 relative">
|
|
<BaseMap ref={mapRef} center={[36.0, 127.0]} zoom={7} height={480} className="w-full rounded-lg overflow-hidden" />
|
|
{/* 범례 */}
|
|
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
|
|
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">함정 경로 · 커버리지</div>
|
|
<div className="space-y-1">
|
|
{FLEET.filter(f => f.status !== '정비중').map(f => (
|
|
<div key={f.id} className="flex items-center gap-1.5">
|
|
<div className="w-4 h-0 border-t-2" style={{ borderColor: f.color }} />
|
|
<span className="text-[8px] text-muted-foreground">{f.name} ({f.zone})</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-1.5 pt-1.5 border-t border-border space-y-0.5">
|
|
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-cyan-500/20 border border-cyan-500/40" /><span className="text-[8px] text-muted-foreground">커버리지 영역 (90%+)</span></div>
|
|
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-blue-500/15 border border-blue-500/30" /><span className="text-[8px] text-muted-foreground">커버리지 영역 (70~90%)</span></div>
|
|
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full border border-dashed border-slate-500/40" /><span className="text-[8px] text-muted-foreground">미배치 영역</span></div>
|
|
</div>
|
|
</div>
|
|
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
|
<Users className="w-3.5 h-3.5 text-purple-400" />
|
|
<span className="text-[10px] text-purple-400 font-bold">{FLEET.filter(f => f.status !== '정비중').length}척 투입</span>
|
|
<span className="text-[9px] text-hint">최적화 커버리지 88%</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</PageContainer>
|
|
);
|
|
}
|