fix: hotfix 동기화 — history/detail candidate_count 안전 처리 #225
@ -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);
|
||||
}
|
||||
}
|
||||
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 { 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;
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user