kcg-ai-monitoring/frontend/src/features/surveillance/LiveMapView.tsx
htlee da4dc86e90 refactor(frontend): 인라인 버튼/하드코딩 색상 전수 제거
Phase C-2 (인라인 <button>):
- TabBar/TabButton 공통 컴포넌트 신규 (underline/pill/segmented 3종)
- DataHub: 메인 탭 → TabBar + TabButton 전환, 필터 pill 전환,
  CTA 버튼 (작업 등록/스토리지 관리/새로고침) → Button variant
- PermissionsPanel: 역할 생성/저장 → Button variant, icon 버튼 유지
- Python 일괄 치환: 51개 inline <button>에 type="button" 추가
- 남은 <button> type 누락 0건 (multi-line 포함)

Phase C-3 (하드코딩 색상):
- AdminPanel SERVER_STATUS 뱃지: getStatusIntent() 사용으로 통일
- bg-X-500/20 text-X-400 패턴 0건

Phase C-4 (인라인 style):
- LiveMapView BaseMap minHeight → className="min-h-[400px]"
- 나머지 89건 style={{}}은 모두 dynamic value
  (progress width, toggle left, 데이터 기반 color 등)로 정당함

4개 catalog (eventStatuses/enforcementResults/enforcementActions/
patrolStatuses)에 intent 필드 추가, statusIntent.ts 공통 유틸 신규.
이제 모든 Badge가 쇼케이스 팔레트 자동 적용됨.

빌드 검증:
- tsc , eslint , vite build 
- 남은 위반 지표: Badge className 0, button-type-missing 0, 하드코딩 색상 0
2026-04-08 12:36:07 +09:00

398 lines
16 KiB
TypeScript

import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import maplibregl from 'maplibre-gl';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer } from '@shared/components/layout';
import { AlertTriangle, Ship, Radio, Zap, Activity, Clock, Pin, Loader2, WifiOff } from 'lucide-react';
import {
fetchVesselAnalysis,
type VesselAnalysisItem,
} from '@/services/vesselAnalysisApi';
import {
getEvents,
type PredictionEvent,
} from '@/services/event';
import { getAlertLevelHex } from '@shared/constants/alertLevels';
interface MapEvent {
id: string;
type: string;
mmsi: string;
nationality: string;
time: string;
vesselName: string;
risk: number;
lat: number;
lng: number;
level: string;
}
const EVENT_COLORS: Record<string, string> = {
'EEZ 침범': '#ef4444',
'다크베셀': '#f97316',
'AIS 신호 소실': '#eab308',
};
const eventIconMap: Record<string, typeof AlertTriangle> = {
'EEZ 침범': AlertTriangle,
'다크베셀': Ship,
'AIS 신호 소실': Radio,
};
const MAX_VESSEL_MARKERS = 100;
function RiskBar({ value, size = 'md' }: { value: number; size?: 'sm' | 'md' }) {
const pct = value * 100;
const h = size === 'sm' ? 'h-1' : 'h-1.5';
return (
<div className="flex items-center gap-2">
<div className={`flex-1 ${h} bg-secondary rounded-full overflow-hidden`}>
<div className={`${h} bg-red-500 rounded-full`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs font-medium text-red-400">{value.toFixed(2)}</span>
</div>
);
}
export function LiveMapView() {
// 실데이터 상태
const [vesselItems, setVesselItems] = useState<VesselAnalysisItem[]>([]);
const [activeEvents, setActiveEvents] = useState<PredictionEvent[]>([]);
const [serviceAvailable, setServiceAvailable] = useState(true);
const [loading, setLoading] = useState(true);
// 데이터 로드
useEffect(() => {
let cancelled = false;
const loadData = async () => {
setLoading(true);
try {
const [analysisRes, eventsRes] = await Promise.all([
fetchVesselAnalysis().catch(() => null),
getEvents({ status: 'NEW,ACK,IN_PROGRESS', size: 10 }).catch(() => null),
]);
if (cancelled) return;
if (analysisRes) {
setServiceAvailable(analysisRes.serviceAvailable);
// riskScore 내림차순 정렬, 최대 100건
const sorted = [...analysisRes.items].sort(
(a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score,
);
setVesselItems(sorted.slice(0, MAX_VESSEL_MARKERS));
} else {
setServiceAvailable(false);
}
setActiveEvents(eventsRes?.content ?? []);
} catch {
setServiceAvailable(false);
} finally {
if (!cancelled) setLoading(false);
}
};
loadData();
return () => { cancelled = true; };
}, []);
// 이벤트 → MapEvent 변환
const mapEvents: MapEvent[] = useMemo(
() =>
activeEvents
.filter((e) => e.lat != null && e.lon != null)
.map((e) => ({
id: String(e.id),
type: e.category,
mmsi: e.vesselMmsi ?? '미상',
nationality: e.vesselMmsi?.startsWith('412') ? 'CN' : e.vesselMmsi?.startsWith('440') ? 'KR' : '미상',
time: e.occurredAt.includes(' ') ? e.occurredAt.split(' ')[1]?.slice(0, 5) ?? e.occurredAt : e.occurredAt,
vesselName: e.vesselName ?? '미상',
risk: e.aiConfidence ?? (e.level === 'CRITICAL' ? 0.94 : e.level === 'HIGH' ? 0.91 : 0.88),
lat: e.lat!,
lng: e.lon!,
level: e.level,
})),
[activeEvents],
);
// 선박 분석 데이터 → 마커용 변환 (좌표 없으므로 zone 기반 더미 좌표 생성)
// vessel_analysis에는 좌표가 없으므로 zone 기반 대략적 배치
const vesselMarkers = useMemo(() => {
// zone → 대략적 좌표 매핑
const ZONE_COORDS: Record<string, { lat: number; lng: number }> = {
WEST_SEA: { lat: 36.5, lng: 124.5 },
SOUTH_SEA: { lat: 33.5, lng: 127.0 },
EAST_SEA: { lat: 37.0, lng: 130.0 },
JEJU: { lat: 33.2, lng: 126.5 },
NLL: { lat: 37.8, lng: 125.0 },
};
const DEFAULT_COORD = { lat: 35.5, lng: 126.5 };
return vesselItems.map((item, idx) => {
const zone = item.algorithms.location.zone;
const base = ZONE_COORDS[zone] ?? DEFAULT_COORD;
// 같은 zone 내에서 약간의 오프셋 추가
const offset = idx * 0.03;
return {
item,
lat: base.lat + (Math.sin(idx * 2.1) * 0.8) + offset * 0.1,
lng: base.lng + (Math.cos(idx * 1.7) * 1.2) + offset * 0.1,
};
});
}, [vesselItems]);
const [selectedEvent, setSelectedEvent] = useState<MapEvent | null>(null);
const mapRef = useRef<MapHandle>(null);
const mapInstanceRef = useRef<maplibregl.Map | null>(null);
// Auto-select first event once loaded
useEffect(() => {
if (mapEvents.length > 0 && !selectedEvent) {
setSelectedEvent(mapEvents[0]);
}
}, [mapEvents, selectedEvent]);
// deck.gl 레이어
const buildLayers = useCallback(() => [
...STATIC_LAYERS,
// 선박 분석 데이터 마커 (riskLevel 기반 색상)
createMarkerLayer(
'ais-vessels',
vesselMarkers.map((v): MarkerData => {
const level = v.item.algorithms.riskScore.level;
const color = getAlertLevelHex(level);
return {
lat: v.lat,
lng: v.lng,
color,
radius: level === 'CRITICAL' ? 900 : level === 'HIGH' ? 750 : 600,
label: v.item.mmsi,
};
}),
),
// 이벤트 경보 반경 원
createRadiusLayer(
'alert-radius',
mapEvents.map((evt) => ({
lat: evt.lat,
lng: evt.lng,
radius: 8000,
color: EVENT_COLORS[evt.type] || '#ef4444',
})),
0.08,
),
// 이벤트 마커 (선택 시 크기 강조)
createMarkerLayer(
'event-markers',
mapEvents.map((evt) => ({
lat: evt.lat,
lng: evt.lng,
color: EVENT_COLORS[evt.type] || '#ef4444',
radius: evt.id === selectedEvent?.id ? 1600 : 1100,
})),
),
], [selectedEvent, mapEvents, vesselMarkers]);
useMapLayers(mapRef, buildLayers, [selectedEvent, mapEvents, vesselMarkers]);
// deck.gl onClick
const handleMapClick = useCallback((info: unknown) => {
const pickInfo = info as { layer?: { id: string }; index?: number };
if (pickInfo.layer?.id === 'event-markers' && pickInfo.index != null) {
const evt = mapEvents[pickInfo.index];
if (evt) setSelectedEvent(evt);
}
}, [mapEvents]);
// 지도 인스턴스 접근 (flyTo용)
const handleMapReady = useCallback((map: maplibregl.Map) => {
mapInstanceRef.current = map;
const first = mapEvents[0];
if (first) {
map.flyTo({ center: [first.lng, first.lat], zoom: 9, speed: 0.6 });
}
}, [mapEvents]);
// 선택 이벤트 변경 시 지도 포커스
useEffect(() => {
const map = mapInstanceRef.current;
if (!map || !selectedEvent) return;
if (!map.isStyleLoaded()) return;
map.flyTo({ center: [selectedEvent.lng, selectedEvent.lat], zoom: 9, speed: 0.6 });
}, [selectedEvent]);
return (
<PageContainer fullBleed className="flex gap-5 h-[calc(100vh-7rem)]">
{/* 좌측: 이벤트 목록 + 지도 */}
<div className="flex-1 flex gap-4 min-w-0">
{/* 이벤트 카드 목록 */}
<div className="w-[260px] shrink-0 space-y-3">
<div>
<h2 className="text-lg font-bold text-heading"> </h2>
<p className="text-[11px] text-hint mt-0.5"> </p>
</div>
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
<span className="ml-2 text-[11px] text-hint"> ...</span>
</div>
)}
{!serviceAvailable && !loading && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-center gap-2">
<WifiOff className="w-4 h-4 text-yellow-400" />
<span className="text-[11px] text-yellow-400 font-medium"> </span>
</div>
<p className="text-[10px] text-hint mt-1"> .</p>
</div>
)}
<div className="space-y-2">
{mapEvents.map((evt) => {
const IconComp = eventIconMap[evt.type] || AlertTriangle;
const isSelected = selectedEvent?.id === evt.id;
return (
<div
key={evt.id}
onClick={() => setSelectedEvent(evt)}
className={`p-3 rounded-lg border cursor-pointer transition-all ${
isSelected
? 'bg-card border-blue-500/40'
: 'bg-card border-[#1F2F3E] hover:border-blue-600/30'
}`}
>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<IconComp className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold text-heading">{evt.type}</span>
</div>
<Pin className="w-3.5 h-3.5 text-hint hover:text-orange-400 transition-colors" />
</div>
<div className="text-[11px] text-hint mb-2">{evt.mmsi} · {evt.nationality} · {evt.time}</div>
<RiskBar value={evt.risk} size="sm" />
</div>
);
})}
{!loading && mapEvents.length === 0 && (
<div className="text-[11px] text-hint text-center py-4"> .</div>
)}
</div>
</div>
{/* 지도 영역 */}
<div className="flex-1 relative rounded-xl overflow-hidden">
<BaseMap ref={mapRef} center={[36.8, 125.3]} zoom={8} height="100%" className="min-h-[400px]" onClick={handleMapClick} 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"> </div>
<div className="space-y-0.5">
{[
{ color: '#ef4444', label: 'CRITICAL' },
{ color: '#f97316', label: 'HIGH' },
{ color: '#3b82f6', label: 'MEDIUM' },
{ color: '#6b7280', label: 'LOW' },
].map((l) => (
<div key={l.label} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: l.color }} />
<span className="text-[8px] text-muted-foreground">{l.label}</span>
</div>
))}
</div>
<div className="flex items-center gap-3 mt-1 pt-1 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">
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] text-red-400 font-bold">LIVE</span>
<span className="text-[9px] text-hint"> {mapEvents.length} · {vesselItems.length}</span>
</div>
</div>
</div>
{/* 우측: 이벤트 상세 패널 */}
{selectedEvent && (
<div className="w-[300px] shrink-0 space-y-3 overflow-y-auto">
<h3 className="text-base font-bold text-heading"> </h3>
{/* 선박 정보 카드 */}
<div className="bg-red-950/40 border border-red-900/40 rounded-xl p-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 bg-red-600/30 rounded-lg flex items-center justify-center">
<Ship className="w-4.5 h-4.5 text-red-400" />
</div>
<div>
<div className="text-heading font-bold text-sm">{selectedEvent.vesselName}</div>
<div className="text-[10px] text-muted-foreground">{selectedEvent.id}</div>
<div className="text-[10px] text-hint">{selectedEvent.mmsi} · {selectedEvent.nationality} · {selectedEvent.time}</div>
</div>
</div>
</div>
{/* 위험도 점수 */}
<Card className="bg-surface-overlay border-slate-700/40">
<CardContent className="p-4">
<div className="text-[10px] text-muted-foreground mb-1"> </div>
<div className="flex items-baseline gap-1 mb-2">
<span className="text-3xl font-bold text-red-400">{Math.round(selectedEvent.risk * 100)}</span>
<span className="text-sm text-hint">/100</span>
</div>
<div className="h-2 bg-switch-background rounded-full overflow-hidden">
<div
className="h-2 bg-gradient-to-r from-red-600 to-red-400 rounded-full transition-all"
style={{ width: `${selectedEvent.risk * 100}%` }}
/>
</div>
</CardContent>
</Card>
{/* AI 판단 근거 */}
<Card className="bg-surface-overlay border-slate-700/40">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Zap className="w-4 h-4 text-blue-400" />
<span className="text-sm text-heading font-medium">AI </span>
<Badge intent="critical" size="md">신뢰도: High</Badge>
</div>
<div className="space-y-3">
<div className="border-l-2 border-red-500 pl-3">
<div className="flex items-center gap-1.5 text-xs">
<AlertTriangle className="w-3 h-3 text-red-400" />
<span className="text-red-400 font-medium">{selectedEvent.type}</span>
</div>
<div className="text-[10px] text-hint mt-0.5">: {selectedEvent.vesselName} ({selectedEvent.mmsi})</div>
</div>
<div className="border-l-2 border-orange-500 pl-3">
<div className="flex items-center gap-1.5 text-xs">
<Activity className="w-3 h-3 text-orange-400" />
<span className="text-orange-400 font-medium"> </span>
</div>
<div className="text-[10px] text-hint mt-0.5">: {selectedEvent.lat.toFixed(4)}, {selectedEvent.lng.toFixed(4)}</div>
</div>
<div className="border-l-2 border-green-500 pl-3">
<div className="flex items-center gap-1.5 text-xs">
<Clock className="w-3 h-3 text-green-400" />
<span className="text-green-400 font-medium"> </span>
</div>
<div className="text-[10px] text-hint mt-0.5">{selectedEvent.time}</div>
</div>
</div>
<p className="text-[10px] text-hint mt-3"> AI , .</p>
</CardContent>
</Card>
</div>
)}
</PageContainer>
);
}