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
398 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|