release: 선단 클러스터 UI #133

병합
htlee develop 에서 main 로 2 commits 를 머지했습니다 2026-03-20 18:19:57 +09:00
5개의 변경된 파일541개의 추가작업 그리고 1개의 파일을 삭제

파일 보기

@ -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

파일 보기

@ -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<List<Map<String, Object>>> getFleetCompanies() {
List<Map<String, Object>> results = jdbcTemplate.queryForList(
"SELECT id, name_cn AS \"nameCn\", name_en AS \"nameEn\" FROM kcg.fleet_companies ORDER BY id"
);
return ResponseEntity.ok(results);
}
}

파일 보기

@ -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<string, VesselAnalysisDto>;
clusters: Map<number, string[]>;
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<Map<number, FleetCompany>>(new Map());
const [expanded, setExpanded] = useState(true);
const [expandedFleet, setExpandedFleet] = useState<number | null>(null);
const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null);
useEffect(() => {
fetchFleetCompanies().then(setCompanies).catch(() => {});
}, []);
// 선박명 → mmsi 맵 (어구 매칭용)
const gearsByParent = useMemo(() => {
const map = new Map<string, Ship[]>(); // parent_mmsi → gears
const gearPattern = /^(.+?)_\d+_\d*$/;
const parentNames = new Map<string, string>(); // 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<string, Ship>();
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 (
<>
{/* 선단 폴리곤 레이어 */}
<Source id="fleet-cluster-fill" type="geojson" data={polygonGeoJSON}>
<Layer
id="fleet-cluster-fill-layer"
type="fill"
paint={{
'fill-color': ['get', 'color'],
'fill-opacity': 0.1,
}}
/>
<Layer
id="fleet-cluster-line-layer"
type="line"
paint={{
'line-color': ['get', 'color'],
'line-opacity': 0.5,
'line-width': 1.5,
}}
/>
</Source>
{/* 2척 선단 라인 */}
<Source id="fleet-cluster-line" type="geojson" data={lineGeoJSON}>
<Layer
id="fleet-cluster-line-only"
type="line"
paint={{
'line-color': ['get', 'color'],
'line-opacity': 0.5,
'line-width': 1.5,
'line-dasharray': [4, 2],
}}
/>
</Source>
{/* 호버 하이라이트 (별도 Source) */}
<Source id="fleet-cluster-hovered" type="geojson" data={hoveredGeoJSON}>
<Layer
id="fleet-cluster-hovered-fill"
type="fill"
paint={{
'fill-color': ['get', 'color'],
'fill-opacity': 0.25,
}}
/>
</Source>
{/* 선단 목록 패널 */}
<div style={panelStyle}>
<div style={headerStyle}>
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
({fleetList.length})
</span>
<button
style={toggleButtonStyle}
onClick={() => setExpanded(prev => !prev)}
aria-label={expanded ? '패널 접기' : '패널 펼치기'}
>
{expanded ? '▲' : '▼'}
</button>
</div>
{expanded && (
<div style={{ maxHeight: 400, overflowY: 'auto', padding: '4px 0' }}>
{fleetList.length === 0 ? (
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
</div>
) : (
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 (
<div key={id}>
{/* 선단 행 */}
<div
onMouseEnter={() => 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',
}}
>
{/* 펼침 토글 */}
<span
onClick={() => setExpandedFleet(prev => (prev === id ? null : id))}
style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}
>
{isOpen ? '▾' : '▸'}
</span>
{/* 색상 인디케이터 */}
<span style={{
width: 8, height: 8, borderRadius: '50%',
backgroundColor: color, flexShrink: 0,
}} />
{/* 회사명 */}
<span
onClick={() => 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}
</span>
{/* 선박 수 */}
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
({mmsiList.length})
</span>
{/* zoom 버튼 */}
<button
onClick={e => { e.stopPropagation(); handleFleetZoom(id); }}
style={{
background: 'none',
border: '1px solid rgba(99,179,237,0.3)',
borderRadius: 3,
color: '#63b3ed',
fontSize: 9,
cursor: 'pointer',
padding: '1px 4px',
flexShrink: 0,
}}
title="이 선단으로 지도 이동"
>
zoom
</button>
</div>
{/* 선단 상세 */}
{isOpen && (
<div style={{
paddingLeft: 22,
paddingRight: 10,
paddingBottom: 6,
fontSize: 10,
color: '#94a3b8',
borderLeft: `2px solid ${color}33`,
marginLeft: 10,
}}>
{/* 선박 목록 */}
<div style={{ color: '#64748b', fontSize: 9, marginBottom: 3 }}>:</div>
{(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 (
<div
key={mmsi}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
marginBottom: 2,
}}
>
<span style={{ flex: 1, color: '#cbd5e1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{displayName}
</span>
<span style={{ color: role === 'LEADER' ? '#fbbf24' : '#64748b', fontSize: 9, flexShrink: 0 }}>
({role === 'LEADER' ? 'MAIN' : 'SUB'})
</span>
<button
onClick={() => onShipSelect?.(mmsi)}
style={{
background: 'none',
border: 'none',
color: '#63b3ed',
fontSize: 10,
cursor: 'pointer',
padding: '0 2px',
flexShrink: 0,
}}
title="선박으로 이동"
aria-label={`${displayName} 선박으로 이동`}
>
</button>
</div>
);
})}
{/* 어구 목록 */}
{gearCount > 0 && (
<>
<div style={{ color: '#64748b', fontSize: 9, marginTop: 4, marginBottom: 2 }}>
: {gearCount}
</div>
{mmsiList.flatMap(mmsi => gearsByParent.get(mmsi) ?? []).map(gear => (
<div key={gear.mmsi} style={{ color: '#475569', fontSize: 9, marginBottom: 1 }}>
{gear.name || gear.mmsi}
</div>
))}
</>
)}
</div>
)}
</div>
);
})
)}
</div>
)}
</div>
</>
);
}
export default FleetClusterLayer;

파일 보기

@ -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 (
<Map
ref={mapRef}
@ -330,6 +338,15 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
{layers.nkMissile && <NKMissileEventLayer ships={ships} />}
{koreaFilters.illegalFishing && <FishingZoneLayer />}
{layers.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
{layers.cnFishing && vesselAnalysis && vesselAnalysis.clusters.size > 0 && (
<FleetClusterLayer
ships={allShips ?? ships}
analysisMap={vesselAnalysis.analysisMap}
clusters={vesselAnalysis.clusters}
onShipSelect={handleAnalysisShipSelect}
onFleetZoom={handleFleetZoom}
/>
)}
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && (
<AnalysisOverlay
ships={allShips ?? ships}

파일 보기

@ -10,3 +10,21 @@ export async function fetchVesselAnalysis(): Promise<VesselAnalysisDto[]> {
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<number, FleetCompany> | null = null;
export async function fetchFleetCompanies(): Promise<Map<number, FleetCompany>> {
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;
}