kcg-ai-monitoring/frontend/src/features/patrol/PatrolRoute.tsx
htlee 2483174081 refactor(frontend): Badge className 위반 37건 전수 제거
- 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 사용처에 일관되게 반영됨.
2026-04-08 12:28:23 +09:00

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>
);
}