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); const [expandedGearGroup, setExpandedGearGroup] = 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]); // 비허가 어구 클러스터: parentName → { parent: Ship | null, gears: Ship[] } const gearGroupMap = useMemo(() => { const gearPattern = /^(.+?)_\d+_\d+_?$/; const nameToShip = new Map(); for (const s of ships) { const nm = (s.name || '').trim(); if (nm && !gearPattern.test(nm)) { nameToShip.set(nm, s); } } const map = new Map(); for (const s of ships) { const m = (s.name || '').match(gearPattern); if (!m) continue; const parentName = m[1].trim(); const entry = map.get(parentName) ?? { parent: nameToShip.get(parentName) ?? null, gears: [] }; entry.gears.push(s); map.set(parentName, entry); } return map; }, [ships]); // 비허가 어구 클러스터 GeoJSON const gearClusterGeoJson = useMemo((): GeoJSON => { const features: GeoJSON.Feature[] = []; for (const [parentName, { parent, gears }] of gearGroupMap) { const points: [number, number][] = gears.map(g => [g.lng, g.lat]); if (parent) points.push([parent.lng, parent.lat]); if (points.length < 3) continue; const hull = convexHull(points); const padded = padPolygon(hull, 0.01); padded.push(padded[0]); features.push({ type: 'Feature', properties: { name: parentName, gearCount: gears.length }, geometry: { type: 'Polygon', coordinates: [padded] }, }); } return { type: 'FeatureCollection', features }; }, [gearGroupMap]); // 어구 그룹 목록 (어구 수 내림차순) const gearGroupList = useMemo(() => { return Array.from(gearGroupMap.entries()) .map(([name, { parent, gears }]) => ({ name, parent, gears })) .sort((a, b) => b.gears.length - a.gears.length); }, [gearGroupMap]); const handleGearGroupZoom = useCallback((parentName: string) => { const entry = gearGroupMap.get(parentName); if (!entry) return; const all: Ship[] = [...entry.gears]; if (entry.parent) all.push(entry.parent); if (all.length === 0) return; let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; for (const s of all) { if (s.lat < minLat) minLat = s.lat; if (s.lat > maxLat) maxLat = s.lat; if (s.lng < minLng) minLng = s.lng; if (s.lng > maxLng) maxLng = s.lng; } if (minLat === Infinity) return; onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); }, [gearGroupMap, onFleetZoom]); // 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}
))} )}
)}
); }) )} {/* 비허가 어구 그룹 섹션 */} {gearGroupList.length > 0 && ( <>
비허가 어구 그룹 ({gearGroupList.length}개)
{gearGroupList.map(({ name, parent, gears }) => { const isOpen = expandedGearGroup === name; return (
{ (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(255,255,255,0.04)'; }} onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; }} > setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }} > {isOpen ? '▾' : '▸'} setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer', }} title={name} > {name} ({gears.length}개)
{isOpen && (
{parent && (
모선: {parent.name || parent.mmsi}
)}
어구 목록:
{gears.map(g => (
{g.name || g.mmsi}
))}
)}
); })} )}
)}
); } export default FleetClusterLayer;