feat: 선단 클러스터 UI — 폴리곤 경계 + 목록 패널 + hover/zoom 인터랙션
- FleetClusterLayer: ConvexHull 폴리곤 + 패딩 + 회사별 색상 - 선단 목록 패널: hover→하이라이트, zoom→fitBounds, 선박/어구 목록 - FleetCompanyController: GET /api/fleet-companies (회사명 조회) - AuthFilter: /api/fleet-* 인증 예외 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
5e359ec296
커밋
83bcbf48ab
@ -25,6 +25,7 @@ public class AuthFilter extends OncePerRequestFilter {
|
|||||||
private static final String CCTV_PATH_PREFIX = "/api/cctv/";
|
private static final String CCTV_PATH_PREFIX = "/api/cctv/";
|
||||||
private static final String VESSEL_ANALYSIS_PATH_PREFIX = "/api/vessel-analysis";
|
private static final String VESSEL_ANALYSIS_PATH_PREFIX = "/api/vessel-analysis";
|
||||||
private static final String PREDICTION_PATH_PREFIX = "/api/prediction/";
|
private static final String PREDICTION_PATH_PREFIX = "/api/prediction/";
|
||||||
|
private static final String FLEET_PATH_PREFIX = "/api/fleet-";
|
||||||
|
|
||||||
private final JwtProvider jwtProvider;
|
private final JwtProvider jwtProvider;
|
||||||
|
|
||||||
@ -35,7 +36,8 @@ public class AuthFilter extends OncePerRequestFilter {
|
|||||||
|| path.startsWith(SENSOR_PATH_PREFIX)
|
|| path.startsWith(SENSOR_PATH_PREFIX)
|
||||||
|| path.startsWith(CCTV_PATH_PREFIX)
|
|| path.startsWith(CCTV_PATH_PREFIX)
|
||||||
|| path.startsWith(VESSEL_ANALYSIS_PATH_PREFIX)
|
|| path.startsWith(VESSEL_ANALYSIS_PATH_PREFIX)
|
||||||
|| path.startsWith(PREDICTION_PATH_PREFIX);
|
|| path.startsWith(PREDICTION_PATH_PREFIX)
|
||||||
|
|| path.startsWith(FLEET_PATH_PREFIX);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
476
frontend/src/components/korea/FleetClusterLayer.tsx
Normal file
476
frontend/src/components/korea/FleetClusterLayer.tsx
Normal file
@ -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 { NKMissileEventLayer } from './NKMissileEventLayer';
|
||||||
import { ChineseFishingOverlay } from './ChineseFishingOverlay';
|
import { ChineseFishingOverlay } from './ChineseFishingOverlay';
|
||||||
import { AnalysisOverlay } from './AnalysisOverlay';
|
import { AnalysisOverlay } from './AnalysisOverlay';
|
||||||
|
import { FleetClusterLayer } from './FleetClusterLayer';
|
||||||
import { FishingZoneLayer } from './FishingZoneLayer';
|
import { FishingZoneLayer } from './FishingZoneLayer';
|
||||||
import { AnalysisStatsPanel } from './AnalysisStatsPanel';
|
import { AnalysisStatsPanel } from './AnalysisStatsPanel';
|
||||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||||
@ -162,6 +163,13 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
setTrackCoords(coords);
|
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 (
|
return (
|
||||||
<Map
|
<Map
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
@ -330,6 +338,15 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
{layers.nkMissile && <NKMissileEventLayer ships={ships} />}
|
{layers.nkMissile && <NKMissileEventLayer ships={ships} />}
|
||||||
{koreaFilters.illegalFishing && <FishingZoneLayer />}
|
{koreaFilters.illegalFishing && <FishingZoneLayer />}
|
||||||
{layers.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
|
{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 && (
|
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && (
|
||||||
<AnalysisOverlay
|
<AnalysisOverlay
|
||||||
ships={allShips ?? ships}
|
ships={allShips ?? ships}
|
||||||
|
|||||||
@ -10,3 +10,21 @@ export async function fetchVesselAnalysis(): Promise<VesselAnalysisDto[]> {
|
|||||||
const data: { count: number; items: VesselAnalysisDto[] } = await res.json();
|
const data: { count: number; items: VesselAnalysisDto[] } = await res.json();
|
||||||
return data.items ?? [];
|
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;
|
||||||
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user