- 4개 catalog(eventStatuses/enforcementResults/enforcementActions/patrolStatuses)에 intent 필드 추가 + getXxxIntent() 헬퍼 신규 - statusIntent.ts 공통 유틸: 한글/영문 상태 문자열 → BadgeIntent 매핑 + getRiskIntent(0-100) 점수 기반 매핑 - 모든 Badge className="..." 패턴을 intent prop으로 치환: - admin (AuditLogs/AccessControl/SystemConfig/NoticeManagement/DataHub) - ai-operations (AIModelManagement/MLOpsPage) - enforcement (EventList/EnforcementHistory) - field-ops (AIAlert) - detection (GearIdentification) - patrol (PatrolRoute/FleetOptimization) - parent-inference (ParentExclusion) - statistics (ExternalService/ReportManagement) - surveillance (MapControl) - risk-assessment (EnforcementPlan) - monitoring (SystemStatusPanel — ServiceCard statusColor → statusIntent 리팩토) - dashboard (Dashboard PatrolStatusBadge) 이제 Badge의 테마별 팔레트(라이트 파스텔 + 다크 translucent)가 자동 적용되며, 쇼케이스에서 palette 조정 시 모든 Badge 사용처에 일관되게 반영됨.
224 lines
11 KiB
TypeScript
224 lines
11 KiB
TypeScript
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import type maplibregl from 'maplibre-gl';
|
|
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createPolylineLayer, 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 { Navigation, Ship, MapPin, Clock, Wind, Anchor, Play, BarChart3, Target, Settings, CheckCircle, Share2 } from 'lucide-react';
|
|
import { usePatrolStore } from '@stores/patrolStore';
|
|
|
|
/* SFR-07: AI 경비함정 단일 함정 순찰·경로 추천 */
|
|
|
|
const RANGE_MAP: Record<string, string> = {
|
|
'태극급': '3,500NM',
|
|
'참수리급': '800NM',
|
|
'삼봉급': '5,000NM',
|
|
};
|
|
|
|
const ROUTE_SHIP_IDS = ['P-3001', 'P-3005', 'P-3009', 'P-5001'];
|
|
|
|
|
|
export function PatrolRoute() {
|
|
const { t } = useTranslation('patrol');
|
|
const { ships, routes, scenarios, load } = usePatrolStore();
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const [selectedShip, setSelectedShip] = useState('P-3001');
|
|
const [selectedScenario, setSelectedScenario] = useState(1);
|
|
|
|
const SHIPS = useMemo(
|
|
() =>
|
|
ships
|
|
.filter((s) => ROUTE_SHIP_IDS.includes(s.id))
|
|
.map((s) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
class: s.shipClass,
|
|
speed: `${s.speed}kt`,
|
|
range: RANGE_MAP[s.shipClass] ?? '-',
|
|
status: ['추적중', '검문중', '초계중'].includes(s.status) ? '출동중' : s.status === '정비중' ? '정비중' : '가용',
|
|
})),
|
|
[ships],
|
|
);
|
|
|
|
const mapRef = useRef<MapHandle>(null);
|
|
const route = routes[selectedShip] || routes['P-3001'];
|
|
const currentShip = SHIPS.find(s => s.id === selectedShip) ?? SHIPS[0];
|
|
|
|
const wps = route?.waypoints ?? [];
|
|
|
|
const buildLayers = useCallback(() => {
|
|
if (wps.length === 0) return [...STATIC_LAYERS];
|
|
|
|
const routeCoords: [number, number][] = wps.map(w => [w.lat, w.lng]);
|
|
const midMarkers = [];
|
|
for (let i = 0; i < wps.length - 1; i++) {
|
|
midMarkers.push({
|
|
lat: (wps[i].lat + wps[i + 1].lat) / 2,
|
|
lng: (wps[i].lng + wps[i + 1].lng) / 2,
|
|
color: '#06b6d4',
|
|
radius: 500,
|
|
});
|
|
}
|
|
const waypointMarkers = wps.map((wp, i) => {
|
|
const isStart = i === 0;
|
|
const isEnd = i === wps.length - 1;
|
|
const color = isStart || isEnd ? '#22c55e' : '#06b6d4';
|
|
return { lat: wp.lat, lng: wp.lng, color, radius: isStart || isEnd ? 1400 : 1000, label: `WP${i + 1}` };
|
|
});
|
|
|
|
return [
|
|
...STATIC_LAYERS,
|
|
createPolylineLayer('patrol-route', routeCoords, { color: '#06b6d4', width: 3, opacity: 0.8 }),
|
|
createMarkerLayer('route-midpoints', midMarkers, '#06b6d4', 500),
|
|
createMarkerLayer('waypoint-markers', waypointMarkers),
|
|
];
|
|
}, [wps]);
|
|
useMapLayers(mapRef, buildLayers, [wps]);
|
|
|
|
const handleMapReady = useCallback((map: maplibregl.Map) => {
|
|
if (wps.length === 0) return;
|
|
const lngs = wps.map(w => w.lng);
|
|
const lats = wps.map(w => w.lat);
|
|
map.fitBounds(
|
|
[[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]],
|
|
{ padding: 40 },
|
|
);
|
|
}, [wps]);
|
|
|
|
const mapCenter: [number, number] = wps.length > 0
|
|
? [wps.reduce((s, w) => s + w.lat, 0) / wps.length, wps.reduce((s, w) => s + w.lng, 0) / wps.length]
|
|
: [36.0, 126.0];
|
|
|
|
if (!currentShip || !route) return null;
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
icon={Navigation}
|
|
iconColor="text-cyan-400"
|
|
title={t('patrolRoute.title')}
|
|
description={t('patrolRoute.desc')}
|
|
demo
|
|
actions={
|
|
<>
|
|
<Button variant="primary" size="sm" icon={<Play className="w-3 h-3" />}>
|
|
경로 생성
|
|
</Button>
|
|
<Button variant="secondary" size="sm" icon={<Share2 className="w-3 h-3" />}>
|
|
공유
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<div className="grid grid-cols-4 gap-3">
|
|
{/* 함정 선택 */}
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><Ship className="w-4 h-4 text-cyan-400" />함정 선택</div>
|
|
<div className="space-y-2">
|
|
{SHIPS.map(s => (
|
|
<div key={s.id} onClick={() => s.status === '가용' && setSelectedShip(s.id)}
|
|
className={`px-3 py-2 rounded-lg cursor-pointer transition-colors ${selectedShip === s.id ? 'bg-cyan-600/20 border border-cyan-500/30' : 'bg-surface-overlay border border-transparent hover:border-border'} ${s.status !== '가용' ? 'opacity-40 cursor-not-allowed' : ''}`}>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-[11px] font-bold text-heading">{s.name}</span>
|
|
<Badge intent={s.status === '가용' ? 'success' : 'critical'} size="xs">{s.status}</Badge>
|
|
</div>
|
|
<div className="text-[9px] text-hint mt-0.5">{s.class} · {s.speed} · {s.range}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 경로 Waypoint */}
|
|
<Card className="col-span-2 bg-surface-raised border-border">
|
|
<CardContent className="p-4">
|
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><MapPin className="w-4 h-4 text-green-400" />추천 순찰 경로 ({currentShip.name})</div>
|
|
<div className="space-y-1.5">
|
|
{route.waypoints.map((wp, i) => (
|
|
<div key={wp.id} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
|
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${i === 0 || i === route.waypoints.length - 1 ? 'bg-green-500/15 border border-green-500/30' : 'bg-cyan-500/15 border border-cyan-500/30'}`}>
|
|
<span className={`text-[9px] font-bold ${i === 0 || i === route.waypoints.length - 1 ? 'text-green-400' : 'text-cyan-400'}`}>{i + 1}</span>
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="text-[11px] text-heading font-medium">{wp.name}</div>
|
|
<div className="text-[9px] text-hint">{wp.lat.toFixed(2)}°N, {wp.lng.toFixed(2)}°E</div>
|
|
</div>
|
|
<span className="text-[10px] text-muted-foreground font-mono">ETA {wp.eta}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-4 mt-3 pt-3 border-t border-border text-[10px]">
|
|
{[['총 거리', route.summary.dist], ['예상 시간', route.summary.time], ['연료 소모', route.summary.fuel], ['커버 격자', route.summary.grids]].map(([k, v]) => (
|
|
<div key={k} className="flex-1 text-center"><div className="text-hint">{k}</div><div className="text-heading font-bold">{v}</div></div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 시나리오 가중치 */}
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><Settings className="w-4 h-4 text-yellow-400" />시나리오 가중치</div>
|
|
<div className="space-y-2">
|
|
{scenarios.map((sc, i) => (
|
|
<div key={sc.name} onClick={() => setSelectedScenario(i)}
|
|
className={`px-3 py-2 rounded-lg cursor-pointer ${selectedScenario === i ? 'bg-yellow-500/10 border border-yellow-500/30' : 'bg-surface-overlay border border-transparent'}`}>
|
|
<div className="flex justify-between">
|
|
<span className="text-[11px] text-heading font-medium">{sc.name}</span>
|
|
<span className="text-[10px] text-yellow-400 font-bold">{sc.score}점</span>
|
|
</div>
|
|
<div className="flex gap-2 mt-1 text-[9px]">
|
|
<span className="text-red-400">위험 {sc.weight.risk}%</span>
|
|
<span className="text-green-400">연료 {sc.weight.fuel}%</span>
|
|
<span className="text-blue-400">시간 {sc.weight.time}%</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-3 pt-3 border-t border-border">
|
|
<div className="text-[10px] text-muted-foreground mb-1">기존 방식 대비 효율성</div>
|
|
<div className="flex justify-between text-[10px]">
|
|
<span className="text-hint">커버리지</span><span className="text-green-400 font-bold">+32%</span>
|
|
</div>
|
|
<div className="flex justify-between text-[10px]">
|
|
<span className="text-hint">연료 절감</span><span className="text-green-400 font-bold">-18%</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 순찰 경로 지도 */}
|
|
<Card>
|
|
<CardContent className="p-0 relative">
|
|
<BaseMap ref={mapRef} key={selectedShip} center={mapCenter} zoom={7} height={480} className="w-full rounded-lg overflow-hidden" onMapReady={handleMapReady} />
|
|
{/* 범례 */}
|
|
<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">
|
|
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-green-500" /><span className="text-[8px] text-muted-foreground">출발/귀항</span></div>
|
|
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-cyan-500" /><span className="text-[8px] text-muted-foreground">경유 초계점</span></div>
|
|
<div className="flex items-center gap-1.5"><div className="w-6 h-0 border-t-2 border-cyan-500" /><span className="text-[8px] text-muted-foreground">순찰 경로</span></div>
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
|
|
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-red-500/50" /><span className="text-[7px] text-hint">EEZ</span></div>
|
|
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-orange-500/60" /><span className="text-[7px] text-hint">NLL</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">
|
|
<Ship className="w-3.5 h-3.5 text-cyan-400" />
|
|
<span className="text-[10px] text-cyan-400 font-bold">{currentShip.name}</span>
|
|
<span className="text-[9px] text-hint">{currentShip.class} · {route.waypoints.length} waypoints · {route.summary.dist}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</PageContainer>
|
|
);
|
|
}
|