diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java index 8b172c1..18c4370 100644 --- a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java @@ -25,6 +25,7 @@ public class AuthFilter extends OncePerRequestFilter { private static final String CCTV_PATH_PREFIX = "/api/cctv/"; private static final String VESSEL_ANALYSIS_PATH_PREFIX = "/api/vessel-analysis"; private static final String PREDICTION_PATH_PREFIX = "/api/prediction/"; + private static final String FLEET_PATH_PREFIX = "/api/fleet-"; private final JwtProvider jwtProvider; @@ -35,7 +36,8 @@ public class AuthFilter extends OncePerRequestFilter { || path.startsWith(SENSOR_PATH_PREFIX) || path.startsWith(CCTV_PATH_PREFIX) || path.startsWith(VESSEL_ANALYSIS_PATH_PREFIX) - || path.startsWith(PREDICTION_PATH_PREFIX); + || path.startsWith(PREDICTION_PATH_PREFIX) + || path.startsWith(FLEET_PATH_PREFIX); } @Override diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/FleetCompanyController.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/FleetCompanyController.java new file mode 100644 index 0000000..2f48b5c --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/FleetCompanyController.java @@ -0,0 +1,27 @@ +package gc.mda.kcg.domain.fleet; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/fleet-companies") +@RequiredArgsConstructor +public class FleetCompanyController { + + private final JdbcTemplate jdbcTemplate; + + @GetMapping + public ResponseEntity>> getFleetCompanies() { + List> results = jdbcTemplate.queryForList( + "SELECT id, name_cn AS \"nameCn\", name_en AS \"nameEn\" FROM kcg.fleet_companies ORDER BY id" + ); + return ResponseEntity.ok(results); + } +} diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx new file mode 100644 index 0000000..fbb14ed --- /dev/null +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -0,0 +1,476 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Source, Layer } from 'react-map-gl/maplibre'; +import type { GeoJSON } from 'geojson'; +import type { Ship, VesselAnalysisDto } from '../../types'; +import { fetchFleetCompanies } from '../../services/vesselAnalysis'; +import type { FleetCompany } from '../../services/vesselAnalysis'; + +interface Props { + ships: Ship[]; + analysisMap: Map; + clusters: Map; + onShipSelect?: (mmsi: string) => void; + onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void; +} + +// 두 벡터의 외적 (2D) — Graham scan에서 왼쪽 회전 여부 판별 +function cross(o: [number, number], a: [number, number], b: [number, number]): number { + return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); +} + +// Graham scan 기반 볼록 껍질 (반시계 방향) +function convexHull(points: [number, number][]): [number, number][] { + const n = points.length; + if (n < 2) return points.slice(); + const sorted = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const lower: [number, number][] = []; + for (const p of sorted) { + while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) { + lower.pop(); + } + lower.push(p); + } + const upper: [number, number][] = []; + for (let i = sorted.length - 1; i >= 0; i--) { + const p = sorted[i]; + while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) { + upper.pop(); + } + upper.push(p); + } + // lower + upper (첫/끝 중복 제거) + lower.pop(); + upper.pop(); + return lower.concat(upper); +} + +// 중심에서 각 꼭짓점 방향으로 padding 확장 +function padPolygon(hull: [number, number][], padding: number): [number, number][] { + if (hull.length === 0) return hull; + const cx = hull.reduce((s, p) => s + p[0], 0) / hull.length; + const cy = hull.reduce((s, p) => s + p[1], 0) / hull.length; + return hull.map(([x, y]) => { + const dx = x - cx; + const dy = y - cy; + const len = Math.sqrt(dx * dx + dy * dy); + if (len === 0) return [x + padding, y + padding] as [number, number]; + const scale = (len + padding) / len; + return [cx + dx * scale, cy + dy * scale] as [number, number]; + }); +} + +// cluster_id 해시 → HSL 색상 +function clusterColor(id: number): string { + const h = (id * 137) % 360; + return `hsl(${h}, 80%, 55%)`; +} + +// HSL 문자열 → hex 근사 (MapLibre는 hsl() 지원하므로 직접 사용 가능) +// GeoJSON feature에 color 속성으로 주입 +interface ClusterPolygonFeature { + type: 'Feature'; + id: number; + properties: { clusterId: number; color: string }; + geometry: { type: 'Polygon'; coordinates: [number, number][][] }; +} + +interface ClusterLineFeature { + type: 'Feature'; + id: number; + properties: { clusterId: number; color: string }; + geometry: { type: 'LineString'; coordinates: [number, number][] }; +} + +type ClusterFeature = ClusterPolygonFeature | ClusterLineFeature; + +export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, onFleetZoom }: Props) { + const [companies, setCompanies] = useState>(new Map()); + const [expanded, setExpanded] = useState(true); + const [expandedFleet, setExpandedFleet] = useState(null); + const [hoveredFleetId, setHoveredFleetId] = useState(null); + + useEffect(() => { + fetchFleetCompanies().then(setCompanies).catch(() => {}); + }, []); + + // 선박명 → mmsi 맵 (어구 매칭용) + const gearsByParent = useMemo(() => { + const map = new Map(); // parent_mmsi → gears + const gearPattern = /^(.+?)_\d+_\d*$/; + const parentNames = new Map(); // name → mmsi + for (const s of ships) { + if (s.name && !gearPattern.test(s.name)) { + parentNames.set(s.name.trim(), s.mmsi); + } + } + for (const s of ships) { + const m = s.name?.match(gearPattern); + if (!m) continue; + const parentMmsi = parentNames.get(m[1].trim()); + if (parentMmsi) { + const arr = map.get(parentMmsi) ?? []; + arr.push(s); + map.set(parentMmsi, arr); + } + } + return map; + }, [ships]); + + // ships map (mmsi → Ship) + const shipMap = useMemo(() => { + const m = new Map(); + for (const s of ships) m.set(s.mmsi, s); + return m; + }, [ships]); + + // GeoJSON 피처 생성 + const polygonFeatures = useMemo((): ClusterFeature[] => { + const features: ClusterFeature[] = []; + for (const [clusterId, mmsiList] of clusters) { + const points: [number, number][] = []; + for (const mmsi of mmsiList) { + const ship = shipMap.get(mmsi); + if (ship) points.push([ship.lng, ship.lat]); + } + if (points.length < 2) continue; + + const color = clusterColor(clusterId); + + if (points.length === 2) { + features.push({ + type: 'Feature', + id: clusterId, + properties: { clusterId, color }, + geometry: { type: 'LineString', coordinates: points }, + }); + continue; + } + + const hull = convexHull(points); + const padded = padPolygon(hull, 0.02); + // 폴리곤 닫기 + const ring = [...padded, padded[0]]; + features.push({ + type: 'Feature', + id: clusterId, + properties: { clusterId, color }, + geometry: { type: 'Polygon', coordinates: [ring] }, + }); + } + return features; + }, [clusters, shipMap]); + + const polygonGeoJSON = useMemo((): GeoJSON => ({ + type: 'FeatureCollection', + features: polygonFeatures.filter(f => f.geometry.type === 'Polygon'), + }), [polygonFeatures]); + + const lineGeoJSON = useMemo((): GeoJSON => ({ + type: 'FeatureCollection', + features: polygonFeatures.filter(f => f.geometry.type === 'LineString'), + }), [polygonFeatures]); + + // 호버 하이라이트용 단일 폴리곤 + const hoveredGeoJSON = useMemo((): GeoJSON => { + if (hoveredFleetId === null) return { type: 'FeatureCollection', features: [] }; + const f = polygonFeatures.find(p => p.properties.clusterId === hoveredFleetId && p.geometry.type === 'Polygon'); + if (!f) return { type: 'FeatureCollection', features: [] }; + return { type: 'FeatureCollection', features: [f] }; + }, [hoveredFleetId, polygonFeatures]); + + const handleFleetZoom = useCallback((clusterId: number) => { + const mmsiList = clusters.get(clusterId) ?? []; + if (mmsiList.length === 0) return; + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; + for (const mmsi of mmsiList) { + const ship = shipMap.get(mmsi); + if (!ship) continue; + if (ship.lat < minLat) minLat = ship.lat; + if (ship.lat > maxLat) maxLat = ship.lat; + if (ship.lng < minLng) minLng = ship.lng; + if (ship.lng > maxLng) maxLng = ship.lng; + } + if (minLat === Infinity) return; + onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); + }, [clusters, shipMap, onFleetZoom]); + + const fleetList = useMemo(() => { + return Array.from(clusters.entries()) + .map(([id, mmsiList]) => ({ id, mmsiList })) + .sort((a, b) => b.mmsiList.length - a.mmsiList.length); + }, [clusters]); + + // 패널 스타일 (AnalysisStatsPanel 패턴) + const panelStyle: React.CSSProperties = { + position: 'absolute', + bottom: 60, + left: 10, + zIndex: 10, + minWidth: 220, + maxWidth: 300, + backgroundColor: 'rgba(12, 24, 37, 0.92)', + border: '1px solid rgba(99, 179, 237, 0.25)', + borderRadius: 8, + color: '#e2e8f0', + fontFamily: 'monospace, sans-serif', + fontSize: 11, + boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + pointerEvents: 'auto', + }; + + const headerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '6px 10px', + borderBottom: expanded ? '1px solid rgba(99, 179, 237, 0.15)' : 'none', + cursor: 'default', + userSelect: 'none', + flexShrink: 0, + }; + + const toggleButtonStyle: React.CSSProperties = { + background: 'none', + border: 'none', + color: '#94a3b8', + cursor: 'pointer', + fontSize: 10, + padding: '0 2px', + lineHeight: 1, + }; + + return ( + <> + {/* 선단 폴리곤 레이어 */} + + + + + + {/* 2척 선단 라인 */} + + + + + {/* 호버 하이라이트 (별도 Source) */} + + + + + {/* 선단 목록 패널 */} +
+
+ + 선단 현황 ({fleetList.length}개) + + +
+ + {expanded && ( +
+ {fleetList.length === 0 ? ( +
+ 선단 데이터 없음 +
+ ) : ( + fleetList.map(({ id, mmsiList }) => { + const company = companies.get(id); + const companyName = company?.nameCn ?? `선단 #${id}`; + const color = clusterColor(id); + const isOpen = expandedFleet === id; + const isHovered = hoveredFleetId === id; + + const mainVessels = mmsiList.filter(mmsi => { + const dto = analysisMap.get(mmsi); + return dto?.algorithms.fleetRole.role === 'LEADER' || dto?.algorithms.fleetRole.role === 'MEMBER'; + }); + const gearCount = mmsiList.reduce((acc, mmsi) => acc + (gearsByParent.get(mmsi)?.length ?? 0), 0); + + return ( +
+ {/* 선단 행 */} +
setHoveredFleetId(id)} + onMouseLeave={() => setHoveredFleetId(null)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 4, + padding: '4px 10px', + cursor: 'pointer', + backgroundColor: isHovered ? 'rgba(255,255,255,0.06)' : 'transparent', + borderLeft: isOpen ? `2px solid ${color}` : '2px solid transparent', + transition: 'background-color 0.1s', + }} + > + {/* 펼침 토글 */} + setExpandedFleet(prev => (prev === id ? null : id))} + style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }} + > + {isOpen ? '▾' : '▸'} + + {/* 색상 인디케이터 */} + + {/* 회사명 */} + setExpandedFleet(prev => (prev === id ? null : id))} + style={{ + flex: 1, + color: '#e2e8f0', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + cursor: 'pointer', + }} + title={company ? `${company.nameCn} / ${company.nameEn}` : `선단 #${id}`} + > + {companyName} + + {/* 선박 수 */} + + ({mmsiList.length}척) + + {/* zoom 버튼 */} + +
+ + {/* 선단 상세 */} + {isOpen && ( +
+ {/* 선박 목록 */} +
선박:
+ {(mainVessels.length > 0 ? mainVessels : mmsiList).map(mmsi => { + const ship = shipMap.get(mmsi); + const dto = analysisMap.get(mmsi); + const role = dto?.algorithms.fleetRole.role ?? 'MEMBER'; + const displayName = ship?.name || mmsi; + return ( +
+ + {displayName} + + + ({role === 'LEADER' ? 'MAIN' : 'SUB'}) + + +
+ ); + })} + + {/* 어구 목록 */} + {gearCount > 0 && ( + <> +
+ 어구: {gearCount}개 +
+ {mmsiList.flatMap(mmsi => gearsByParent.get(mmsi) ?? []).map(gear => ( +
+ {gear.name || gear.mmsi} +
+ ))} + + )} +
+ )} +
+ ); + }) + )} +
+ )} +
+ + ); +} + +export default FleetClusterLayer; diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 2e66ec4..fce6c06 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -22,6 +22,7 @@ import { NKLaunchLayer } from './NKLaunchLayer'; import { NKMissileEventLayer } from './NKMissileEventLayer'; import { ChineseFishingOverlay } from './ChineseFishingOverlay'; import { AnalysisOverlay } from './AnalysisOverlay'; +import { FleetClusterLayer } from './FleetClusterLayer'; import { FishingZoneLayer } from './FishingZoneLayer'; import { AnalysisStatsPanel } from './AnalysisStatsPanel'; import { getMarineTrafficCategory } from '../../utils/marineTraffic'; @@ -162,6 +163,13 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF setTrackCoords(coords); }, []); + const handleFleetZoom = useCallback((bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => { + mapRef.current?.fitBounds( + [[bounds.minLng, bounds.minLat], [bounds.maxLng, bounds.maxLat]], + { padding: 60, duration: 1500 }, + ); + }, []); + return ( } {koreaFilters.illegalFishing && } {layers.cnFishing && } + {layers.cnFishing && vesselAnalysis && vesselAnalysis.clusters.size > 0 && ( + + )} {vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && ( { const data: { count: number; items: VesselAnalysisDto[] } = await res.json(); return data.items ?? []; } + +export interface FleetCompany { + id: number; + nameCn: string; + nameEn: string; +} + +// 캐시 (세션 중 1회 로드) +let companyCache: Map | null = null; + +export async function fetchFleetCompanies(): Promise> { + if (companyCache) return companyCache; + const res = await fetch(`${API_BASE}/fleet-companies`); + if (!res.ok) return new Map(); + const items: FleetCompany[] = await res.json(); + companyCache = new Map(items.map(c => [c.id, c])); + return companyCache; +}