diff --git a/frontend/src/components/korea/AnalysisStatsPanel.tsx b/frontend/src/components/korea/AnalysisStatsPanel.tsx index d1c86ee..63b1508 100644 --- a/frontend/src/components/korea/AnalysisStatsPanel.tsx +++ b/frontend/src/components/korea/AnalysisStatsPanel.tsx @@ -9,6 +9,7 @@ interface Props { isLoading: boolean; analysisMap: Map; ships: Ship[]; + allShips?: Ship[]; onShipSelect?: (mmsi: string) => void; onTrackLoad?: (mmsi: string, coords: [number, number][]) => void; } @@ -70,7 +71,7 @@ const LEGEND_LINES = [ '스푸핑: 순간이동+SOG급변+BD09 종합', ]; -export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, onShipSelect, onTrackLoad }: Props) { +export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, allShips, onShipSelect, onTrackLoad }: Props) { const [expanded, setExpanded] = useState(true); const [selectedLevel, setSelectedLevel] = useState(null); const [selectedMmsi, setSelectedMmsi] = useState(null); @@ -78,6 +79,23 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, const isEmpty = useMemo(() => stats.total === 0, [stats.total]); + const gearStats = useMemo(() => { + const source = allShips ?? ships; + const gearPattern = /^(.+?)_\d+_\d+_?$/; + const parentMap = new Map(); + for (const s of source) { + const m = (s.name || '').match(gearPattern); + if (m) { + const parent = m[1].trim(); + parentMap.set(parent, (parentMap.get(parent) || 0) + 1); + } + } + return { + groups: parentMap.size, + count: Array.from(parentMap.values()).reduce((a, b) => a + b, 0), + }; + }, [allShips, ships]); + const vesselList = useMemo((): VesselListItem[] => { if (!selectedLevel) return []; const list: VesselListItem[] = []; @@ -244,6 +262,18 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, 선단수 {stats.clusterCount} + {gearStats.groups > 0 && ( + <> +
+ 어구그룹 + {gearStats.groups} +
+
+ 어구수 + {gearStats.count} +
+ + )}
diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index fbb14ed..2365f95 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -88,6 +88,7 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, 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(() => {}); @@ -123,6 +124,71 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, 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[] = []; @@ -291,6 +357,27 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, /> + {/* 비허가 어구 클러스터 폴리곤 */} + + + + + {/* 선단 목록 패널 */}
@@ -307,7 +394,7 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
{expanded && ( -
+
{fleetList.length === 0 ? (
선단 데이터 없음 @@ -466,6 +553,138 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, ); }) )} + + {/* 비허가 어구 그룹 섹션 */} + {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} + + +
+ ))} +
+ )} +
+ ); + })} + + )}
)}
diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index fce6c06..d9283c4 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -451,6 +451,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF isLoading={vesselAnalysis.isLoading} analysisMap={vesselAnalysis.analysisMap} ships={allShips ?? ships} + allShips={allShips ?? ships} onShipSelect={handleAnalysisShipSelect} onTrackLoad={handleTrackLoad} />